Skip to content

fix(apple-runner): evict stale runner cache keys after a build - #3247

Open
janicduplessis wants to merge 3 commits into
callstack:mainfrom
janicduplessis:fix/apple-runner-cache-eviction
Open

janicduplessis wants to merge 3 commits into
callstack:mainfrom
janicduplessis:fix/apple-runner-cache-eviction

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Runner cache keys under ~/.agent-device/apple-runner/derived/<platform>/cache-<hash> are never removed. Any runner source or Xcode change mints a new 150-230 MB key and the old one can never match again; one Mac reached 3.45 GB across 20 keys. Details and measurements are in #3246.

After a build (the only event that adds a key), the sweep removes sibling keys in the same platform folder unless they are:

  • the key just built, or among the 3 most recently used (new key included) by .agent-device-runner-cache.json mtime, which every reuse rewrites;
  • used within the last day, re-checked under the lock, which covers a runner that resolved its key but has not written its lease yet;
  • held by a build (cache-<hash>.lock, acquired without waiting);
  • named, by xctestrun path or cache key, by a live runner lease (owner live or unknown, or the leased runner process still running).

AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n> sets the count, 0 disables. A set AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH is never swept. The sweep runs off the start path, is best-effort, and loads lazily so the eager closure budgets are unchanged.

Not in this PR: trimming build scratch inside a kept key (about 97% of each key), and the un-keyed pnpm build:package output. Both are listed in #3246.

Closes #3246

Validation

Tested at 0542d32. pnpm check:affected --run passed, plus pnpm check:fallow --base origin/main, the eager-closure and package-closure tests, and all of packages/platform-apple/src/runner. New tests cover keep count, the one-day floor, stubs, non-key entries, a held lock, live, dead and handed-off leases, 0, the path override, and the sweep after a real ensureXctestrunArtifact build with a stubbed xcodebuild.

I ran the sweep against a metadata-only copy of a real 20-key cache and its 44 leases. It kept 3 keys per platform and would free about 2.1 GB of 3.45 GB (1.5 GB iOS simulator, 0.6 GB macOS).

Not run: a live simulator build through the hook (it would delete other agents' keys in the shared cache home), and a physical device.

@janicduplessis

Copy link
Copy Markdown
Contributor Author

Automated review (Claude): no data-loss bug on the default path; two narrow races/gaps worth fixing, the rest checked and fine.

Confirmed (low to medium)

  1. Idle check uses a stale snapshot, not re-checked under the lock. runner-cache-retention.ts computes lastUsedMs for every key once (listCacheKeyDirectories, then the 24h filter at the .slice(keep - 1).filter(...) chain, ~L44-47), then deletes candidates one by one, each rm -r taking seconds. A process whose runner key is K (a different checkout/Xcode than the sweeper's) can reuse K after the snapshot: tryReuseExistingXctestrun rewrites the metadata (fresh mtime) and releases the cache lock. Before writeRunnerLease runs (runner-session.ts ~L321, after port allocation, plist rewrite and xcodebuild spawn) the sweeper takes K's lock, finds no lease, and deletes K. The 24h floor exists to cover exactly this gap but is evaluated before the lock, so it does not. Fix: in evictIfUnused, after acquiring the lock, re-stat the metadata mtime and skip if nowMs - mtime < MIN_IDLE_MS (also keeps the same-snapshot ordering for keep ranking out of the picture). Requires K idle more than 24h at snapshot time plus two processes with different keys, so rare.

  2. Lease protection is path-prefix based and misses leases whose xctestrun is outside the cache root. listActiveRunnerLeaseXctestrunPaths is matched with xctestrunPath.startsWith(derived + sep). prepareXctestrunWithEnv (runner-artifact-env.ts L121) writes the per-session .xctestrun into iosXctestEnvDir when set, and that path is what runner-session.ts stores in the lease. With iosXctestEnvDir configured, a live runner (owner alive, daemon up for days, key not current and not reused for >24h) is not recognized as using its key and its products get deleted. The lease already records cacheKey (runner-lease.ts L121/138, equal to basename(derived) via resolveRunnerCacheKey); matching on lease.cacheKey === path.basename(derived) in addition to the prefix would cover this. The unit test seeds leases with paths under derived/Build/Products, so it cannot see this.

  3. (Minor) fs.promises.rm(derived, {recursive}) is not atomic. If the process dies mid-delete and the metadata file is already gone but products remain, the next start gets cache_metadata_missing, which ensureXctestrunUnderCacheLock deliberately does not clean, so xcodebuild builds into the leftover tree and the manifest then certifies whatever is in the product paths. If the metadata file survives, validateRunnerCacheArtifactManifest returns artifact_manifest_missing or artifact_content_mismatch and the tree is cleaned and rebuilt, which is fine. Cheap hardening: unlink the metadata file first, or rename derived to a non-matching sibling before rm. Leaving as-is is defensible since an aborted-build stub already behaves this way.

Checked and fine

  • (1) Race between lock release and lease write: only the case in item 1 gets through. Same-process concurrent ensure for the same key is safe: the sweeper's acquireRunnerXctestrunCacheLock(K, 0) sees a held claim whose token is in liveClaimTokens (judgeStandingClaim, not a spent own claim) and reports busy, so it skips. Stubs with no metadata (lastUsedMs 0) are only deleted if the lock is free, so an in-progress first build holding the lock is safe. The lock dir is a sibling (<key>.lock), so rm of the key does not touch it. A lease with live owner or unknown liveness counts as active; a dead owner with a verified runner pid (handed-off runner) also counts; runnerPid: null with a dead owner is correctly treated as a leftover.
  • (2) Keep arithmetic: the current key is filtered out before the sort, so slice(keep - 1) keeps the keep - 1 most recent others: keep=1 keeps none beyond current (all subject only to the 24h floor), keep=2 keeps the newest other, keep=3 the newest two. A current key that is not newest by mtime does not matter. The 24h floor is an AND with the rank cut, as documented. keep=0 or an unparsable value returns early or falls back to 3. OK.
  • (3) listRunnerLeasesForOwner refactor: filter order changed from per-entry continue to a post-filter on the same predicates; identical results, including the startTime === undefined case. OK.
  • (4) void evictStaleRunnerCachesBestEffort: the dynamic import and the sweep are inside one try/catch, and per-key errors are swallowed, so no unhandled rejection. Eviction runs in the daemon (prepare ios-runner is a daemon handler), not a short-lived CLI, so exit mid-delete is only the crash case in item 3. Metadata mtime is refreshed on every reuse (writeRunnerCacheMetadata is called from tryReuseExistingXctestrun), so the last-used signal is valid.
  • (5) acquireProcessLock({timeoutMs: 0}): params.timeoutMs ?? DEFAULT keeps 0 (nullish, not falsy), the do/while makes exactly one tryAcquireProcessLock, remaining <= 0 breaks, and it throws process_lock_timeout; evictIfUnused catches that and returns false. A dead-owner or recycled-pid lock is reclaimable on that single attempt (judgeStandingClaim returns reclaimable), unless the reclaim mutex is momentarily held, in which case it reports busy and the key is simply skipped until the next sweep.

@thymikee

thymikee commented Oct 6, 2026

Copy link
Copy Markdown
Member

The eviction logic in 0542d32 looks correct, but the build-then-sweep route has no live proof yet. The sweep runs in the daemon right after a real build, and only unit fixtures with a stubbed xcodebuild cover it. Two things are unproven: a real start that builds still answers while the sweep deletes 150-230 MB trees, and the stale_cache_evicted decision fires on the real route. Please run this on a booted iOS simulator from this head. Seed ~/.agent-device/apple-runner/derived/ios-simulator/cache-ffffffffffffffff/Build/Products/ with no metadata file, so it ranks last. Set AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP to the number of real keys other than the one being built, plus 1, so no real key ranks past the cut. Start the daemon with AGENT_DEVICE_IOS_CLEAN_DERIVED=1 to force a rebuild, then run agent-device open <app> --platform ios and a snapshot in the same session. Three outputs prove it: diagnostics showing built_new then stale_cache_evicted for the stub, the stub directory gone with every other cache-* directory intact, and the snapshot succeeding.

Not blocking, take or leave: the comment at runner-cache-retention.ts:100 says the next build cleans a half-deleted key, but the build deliberately skips cleanup on cache_metadata_missing and builds over it, so "never a hit; the next build for this key overwrites it" is accurate. No test reaches the under-lock mtime re-check at line 92 or the metadata-first unlink, because the snapshot filter already excludes fresh keys. A test that touches a key's metadata after the listing and asserts it survives would cover both. listActiveRunnerLeaseArtifacts at runner-lease.ts:479 re-derives the live/unknown owner rule that classifyRunnerLease already owns. The empty catches at runner-artifact.ts:212 and retention.ts:50 swallow every eviction failure, so a debug diagnostic there would help while the start path stays non-failing.

I looked for a smaller design and found none materially smaller, since the PR already reuses the cache lock, the lease store and the decision emitter. Could the liveness filter go through classifyRunnerLease and drop the inline rule?

The Smoke failure is likely unrelated. It fails in the iOS XCTest regression step, which runs raw xcodebuild test-without-building and never reaches the daemon code in this PR. That job also sets AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH, which makes the sweep inert at retention.ts:39. I did not run the unit, eager-closure or fallow checks, so I have not verified the PR body's claims about them. Before merge, the live simulator run above must show the build-then-sweep route working with the session still answering.

@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

🟡 Changes recommended

Lease-state and filesystem-read failures can allow eviction of cache keys still used by live runners.

Review effort: Balanced
Findings: 2 High severity · 1 Low severity

Open (3)
What changed in this PR

Adds bounded, lease-aware retention for stale Apple runner cache keys after successful builds.

Changes:

  • Adds configurable cache eviction with lock, age, and lease safeguards.
  • Runs eviction asynchronously after builds.
  • Adds tests and user documentation for retention behavior.
File Description
website/​docs/​docs/​configuration.md Documents the retention setting.
website/​docs/​docs/​commands.md Explains automatic cache eviction.
packages/​platform-apple/​src/​runner/​runner-lease.ts Exposes active lease artifacts.
packages/​platform-apple/​src/​runner/​runner-cache.ts Supports non-waiting locks and eviction diagnostics.
packages/​platform-apple/​src/​runner/​runner-cache-retention.ts Implements stale-key eviction.
packages/​platform-apple/​src/​runner/​runner-artifact.ts Triggers eviction after builds.
packages/​platform-apple/​src/​runner/​__tests__/​runner-cache-retention.test.ts Covers retention scenarios and build integration.

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

Comment on lines +476 to +477
export function listActiveRunnerLeaseArtifacts(): { xctestrunPath: string; cacheKey?: string }[] {
return listRunnerLeases()
Comment thread packages/platform-apple/src/runner/runner-lease.ts Outdated
| Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public |
| App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is how many runner cache keys per platform survive a new build (default 3, `0` keeps all). |

@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.

9 issues found across 7 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/__tests__/runner-cache-retention.test.ts">

<violation number="1" location="packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts:257">
P3: The stale key's mtime is anchored to the file's fixed `NOW_MS` (2026-10-05), but the sweep triggered inside `ensureXctestrunArtifact` runs with the real `Date.now()`. Until the ambient clock passes ~2026-09-05, `nowMs - lastUsedMs` is negative and key(7) is never evicted, so `vi.waitFor` times out and the test fails purely based on wall-clock date. Seed the stale key with a mtime relative to `Date.now()` (e.g. pass a 30-day-old timestamp computed at test setup) so the assertion does not depend on the machine clock being past a fixed date.</violation>
</file>

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

<violation number="1" location="website/docs/docs/commands.md:284">
P3: This sweep is best-effort: builds do not await it, and cleanup failures are swallowed, so stale keys can remain. Qualify the deletion as best-effort.</violation>

<violation number="2" location="website/docs/docs/commands.md:284">
P3: `derived/<platform>/` is not the real layout: keyed cache directories are named per platform *and* device kind. `resolveRunnerDerivedBasePath` (runner-cache-metadata.ts) joins `RUNNER_DERIVED_ROOT/derived` with `resolveRunnerDerivedBaseName`, whose values are `ios-simulator`, `ios-device`, `tvos-simulator`, `tvos-device`, `macos`, `visionos-simulator`, `visionos-device` (apple-runner-platform.ts `derivedBaseName`). A reader following the docs will look for `~/.agent-device/apple-runner/derived/ios/`, which never exists. The same looseness works into the configuration.md wording: the keep count applies per platform-kind folder (e.g. `ios-simulator` and `ios-device` are swept independently), not per platform.</violation>
</file>

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

<violation number="1" location="packages/platform-apple/src/runner/runner-lease.ts:454">
P2: A lease-directory read failure is treated as no leases, so eviction can delete a cache still used by another runner. Distinguish `ENOENT` from scan errors and abort eviction on other failures.</violation>

<violation number="2" location="packages/platform-apple/src/runner/runner-lease.ts:483">
P1: Treat `owner-state-dir-gone` as active here; otherwise a live lease owner can lose its cache when runner identity probing is unavailable.</violation>

<violation number="3" location="packages/platform-apple/src/runner/runner-lease.ts:484">
P1: A transient failure reading the recorded runner PID's start time can make this function report an active runner lease as inactive, allowing the cache sweep to delete products and the `.xctestrun` file while that runner is still using them. Treat a live but unverified PID as retained here; identity uncertainty is safe to resolve as extra retention, not eviction.</violation>
</file>

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

<violation number="1" location="website/docs/docs/configuration.md:133">
P3: This wording conflicts with the new managed-cache sweep: managed keys are evicted after builds, while `.tmp/` only constrains cleanup of custom derived-path overrides. Clarify the two cleanup paths so operators do not infer that the managed cache is never cleaned.</violation>

<violation number="2" location="website/docs/docs/configuration.md:133">
P3: Describe this as the number of most-recent keys guaranteed to survive; keys used within the last day, locked by a build, or referenced by an active lease can also survive.</violation>
</file>

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

<violation number="1" location="packages/platform-apple/src/runner/runner-cache-retention.ts:50">
P3: `evictIfUnused` failures are swallowed by the empty `catch {}` in the eviction loop, and `listCacheKeyDirectories`/`lastUsedMs` silently return `[]`/`0` on any fs error. A sweep that can't read the base directory or fails to remove a key reports an empty result with no diagnostic, so an operator cannot tell that the cleanup is not running — the feature's whole point is reclaiming disk. Emit a warn diagnostic (like `process_lock_release_unverified` does in host-kit) when a candidate cannot be evicted.</violation>
</file>

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

Turn on auto-fix | Re-trigger cubic

...(lease.ownerStateDir ? { stateDir: lease.ownerStateDir } : {}),
});
if (liveness === 'live' || liveness === 'unknown') return true;
return lease.runnerPid !== null && isLeaseRunnerProcessIntact(lease, lease.runnerPid);

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 transient failure reading the recorded runner PID's start time can make this function report an active runner lease as inactive, allowing the cache sweep to delete products and the .xctestrun file while that runner is still using them. Treat a live but unverified PID as retained here; identity uncertainty is safe to resolve as extra retention, not eviction.

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-lease.ts, line 484:

<comment>A transient failure reading the recorded runner PID's start time can make this function report an active runner lease as inactive, allowing the cache sweep to delete products and the `.xctestrun` file while that runner is still using them. Treat a live but unverified PID as retained here; identity uncertainty is safe to resolve as extra retention, not eviction.</comment>

<file context>
@@ -455,14 +463,29 @@ function listRunnerLeasesForOwner(owner: {
+        ...(lease.ownerStateDir ? { stateDir: lease.ownerStateDir } : {}),
+      });
+      if (liveness === 'live' || liveness === 'unknown') return true;
+      return lease.runnerPid !== null && isLeaseRunnerProcessIntact(lease, lease.runnerPid);
+    })
+    .map(({ xctestrunPath, cacheKey }) => ({ xctestrunPath, cacheKey }));
</file context>
Suggested change
return lease.runnerPid !== null && isLeaseRunnerProcessIntact(lease, lease.runnerPid);
return (
lease.runnerPid !== null &&
(isLeaseRunnerProcessIntact(lease, lease.runnerPid) || isProcessAlive(lease.runnerPid))
);

owner: { pid: lease.ownerPid, startTime: lease.ownerStartTime },
...(lease.ownerStateDir ? { stateDir: lease.ownerStateDir } : {}),
});
if (liveness === 'live' || liveness === 'unknown') return true;

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: Treat owner-state-dir-gone as active here; otherwise a live lease owner can lose its cache when runner identity probing is unavailable.

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-lease.ts, line 483:

<comment>Treat `owner-state-dir-gone` as active here; otherwise a live lease owner can lose its cache when runner identity probing is unavailable.</comment>

<file context>
@@ -455,14 +463,29 @@ function listRunnerLeasesForOwner(owner: {
+        owner: { pid: lease.ownerPid, startTime: lease.ownerStartTime },
+        ...(lease.ownerStateDir ? { stateDir: lease.ownerStateDir } : {}),
+      });
+      if (liveness === 'live' || liveness === 'unknown') return true;
+      return lease.runnerPid !== null && isLeaseRunnerProcessIntact(lease, lease.runnerPid);
+    })
</file context>
Suggested change
if (liveness === 'live' || liveness === 'unknown') return true;
if (liveness === 'live' || liveness === 'unknown' || liveness === 'owner-state-dir-gone') return true;

);
}

function listRunnerLeases(): RunnerLease[] {

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: A lease-directory read failure is treated as no leases, so eviction can delete a cache still used by another runner. Distinguish ENOENT from scan errors and abort eviction on other failures.

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-lease.ts, line 454:

<comment>A lease-directory read failure is treated as no leases, so eviction can delete a cache still used by another runner. Distinguish `ENOENT` from scan errors and abort eviction on other failures.</comment>

<file context>
@@ -444,6 +444,14 @@ function listRunnerLeasesForOwner(owner: {
+  );
+}
+
+function listRunnerLeases(): RunnerLease[] {
   let entries: fs.Dirent[];
   const root = resolveRunnerLeaseRoot();
</file context>

'ios-simulator',
);
base = simulatorBase;
const stale = seedKey(key(7), 30);

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 stale key's mtime is anchored to the file's fixed NOW_MS (2026-10-05), but the sweep triggered inside ensureXctestrunArtifact runs with the real Date.now(). Until the ambient clock passes ~2026-09-05, nowMs - lastUsedMs is negative and key(7) is never evicted, so vi.waitFor times out and the test fails purely based on wall-clock date. Seed the stale key with a mtime relative to Date.now() (e.g. pass a 30-day-old timestamp computed at test setup) so the assertion does not depend on the machine clock being past a fixed date.

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-retention.test.ts, line 257:

<comment>The stale key's mtime is anchored to the file's fixed `NOW_MS` (2026-10-05), but the sweep triggered inside `ensureXctestrunArtifact` runs with the real `Date.now()`. Until the ambient clock passes ~2026-09-05, `nowMs - lastUsedMs` is negative and key(7) is never evicted, so `vi.waitFor` times out and the test fails purely based on wall-clock date. Seed the stale key with a mtime relative to `Date.now()` (e.g. pass a 30-day-old timestamp computed at test setup) so the assertion does not depend on the machine clock being past a fixed date.</comment>

<file context>
@@ -0,0 +1,267 @@
+    'ios-simulator',
+  );
+  base = simulatorBase;
+  const stale = seedKey(key(7), 30);
+  const unrelatedPlatform = path.join(path.dirname(simulatorBase), 'macos', key(8));
+  fs.mkdirSync(unrelatedPlatform, { recursive: true });
</file context>

- 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.
- 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.
- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>/`. After a build, Agent Device deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.

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: This sweep is best-effort: builds do not await it, and cleanup failures are swallowed, so stale keys can remain. Qualify the deletion as best-effort.

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 284:

<comment>This sweep is best-effort: builds do not await it, and cleanup failures are swallowed, so stale keys can remain. Qualify the deletion as best-effort.</comment>

<file context>
@@ -281,6 +281,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000
 - 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.
 - 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.
+- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>/`. After a build, Agent Device deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.
 - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal.
 - Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues.
</file context>
Suggested change
- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>/`. After a build, Agent Device deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.
Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>/`. After a build, Agent Device best-effort deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.

| Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public |
| App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is how many runner cache keys per platform survive a new build (default 3, `0` keeps all). |

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: This wording conflicts with the new managed-cache sweep: managed keys are evicted after builds, while .tmp/ only constrains cleanup of custom derived-path overrides. Clarify the two cleanup paths so operators do not infer that the managed cache is never cleaned.

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/configuration.md, line 133:

<comment>This wording conflicts with the new managed-cache sweep: managed keys are evicted after builds, while `.tmp/` only constrains cleanup of custom derived-path overrides. Clarify the two cleanup paths so operators do not infer that the managed cache is never cleaned.</comment>

<file context>
@@ -130,7 +130,7 @@ These env vars are the supported user-facing configuration surface. Other `AGENT
 | Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public |
 | App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. |
-| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. |
+| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is how many runner cache keys per platform survive a new build (default 3, `0` keeps all). |
 | Install/update and platform helpers | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN`, `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION` | Public operator controls |
 | macOS app backend | `AGENT_DEVICE_MACOS_APP_BACKEND`, `AGENT_DEVICE_MACOS_GHOST_CURSOR` | Public operator controls, read by the daemon. `native` drives macOS app sessions through the macOS helper instead of XCTest; see [Commands](/docs/commands). Unset or `xctest` keeps the runner. The drawn agent pointer adds about 0.3 s to each native click, fill, type, and scroll; `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` turns it off. Restart the daemon after changing either value. |
</file context>
Suggested change
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is how many runner cache keys per platform survive a new build (default 3, `0` keeps all). |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Forced cleanup of override paths is allowed only under project `.tmp/`; keyed-cache eviction applies to managed cache paths, not `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` overrides. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` sets how many most-recent cache keys per platform are retained (default 3, `0` disables eviction). |

- 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.
- 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.
- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>/`. After a build, Agent Device deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.

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: derived/<platform>/ is not the real layout: keyed cache directories are named per platform and device kind. resolveRunnerDerivedBasePath (runner-cache-metadata.ts) joins RUNNER_DERIVED_ROOT/derived with resolveRunnerDerivedBaseName, whose values are ios-simulator, ios-device, tvos-simulator, tvos-device, macos, visionos-simulator, visionos-device (apple-runner-platform.ts derivedBaseName). A reader following the docs will look for ~/.agent-device/apple-runner/derived/ios/, which never exists. The same looseness works into the configuration.md wording: the keep count applies per platform-kind folder (e.g. ios-simulator and ios-device are swept independently), not per platform.

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 284:

<comment>`derived/<platform>/` is not the real layout: keyed cache directories are named per platform *and* device kind. `resolveRunnerDerivedBasePath` (runner-cache-metadata.ts) joins `RUNNER_DERIVED_ROOT/derived` with `resolveRunnerDerivedBaseName`, whose values are `ios-simulator`, `ios-device`, `tvos-simulator`, `tvos-device`, `macos`, `visionos-simulator`, `visionos-device` (apple-runner-platform.ts `derivedBaseName`). A reader following the docs will look for `~/.agent-device/apple-runner/derived/ios/`, which never exists. The same looseness works into the configuration.md wording: the keep count applies per platform-kind folder (e.g. `ios-simulator` and `ios-device` are swept independently), not per platform.</comment>

<file context>
@@ -281,6 +281,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000
 - 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.
 - 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.
+- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>/`. After a build, Agent Device deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.
 - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal.
 - Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues.
</file context>
Suggested change
- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>/`. After a build, Agent Device deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.
+ - Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived/<platform>-<simulator|device>/` (for example `ios-simulator`, `ios-device`, `tvos-device`). After a build, Agent Device deletes keys beside the new one that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a live runner lease points at or a build is holding. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=<n>` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept.

for (const { derived } of candidates) {
try {
if (await evictIfUnused(derived, nowMs)) evicted.push(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.

P3: evictIfUnused failures are swallowed by the empty catch {} in the eviction loop, and listCacheKeyDirectories/lastUsedMs silently return []/0 on any fs error. A sweep that can't read the base directory or fails to remove a key reports an empty result with no diagnostic, so an operator cannot tell that the cleanup is not running — the feature's whole point is reclaiming disk. Emit a warn diagnostic (like process_lock_release_unverified does in host-kit) when a candidate cannot be evicted.

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-retention.ts, line 50:

<comment>`evictIfUnused` failures are swallowed by the empty `catch {}` in the eviction loop, and `listCacheKeyDirectories`/`lastUsedMs` silently return `[]`/`0` on any fs error. A sweep that can't read the base directory or fails to remove a key reports an empty result with no diagnostic, so an operator cannot tell that the cleanup is not running — the feature's whole point is reclaiming disk. Emit a warn diagnostic (like `process_lock_release_unverified` does in host-kit) when a candidate cannot be evicted.</comment>

<file context>
@@ -0,0 +1,108 @@
+  for (const { derived } of candidates) {
+    try {
+      if (await evictIfUnused(derived, nowMs)) evicted.push(derived);
+    } catch {}
+  }
+  return evicted;
</file context>

| Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public |
| App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is how many runner cache keys per platform survive a new build (default 3, `0` keeps all). |

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: Describe this as the number of most-recent keys guaranteed to survive; keys used within the last day, locked by a build, or referenced by an active lease can also survive.

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/configuration.md, line 133:

<comment>Describe this as the number of most-recent keys guaranteed to survive; keys used within the last day, locked by a build, or referenced by an active lease can also survive.</comment>

<file context>
@@ -130,7 +130,7 @@ These env vars are the supported user-facing configuration surface. Other `AGENT
 | Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public |
 | App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. |
-| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. |
+| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is how many runner cache keys per platform survive a new build (default 3, `0` keeps all). |
 | Install/update and platform helpers | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN`, `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION` | Public operator controls |
 | macOS app backend | `AGENT_DEVICE_MACOS_APP_BACKEND`, `AGENT_DEVICE_MACOS_GHOST_CURSOR` | Public operator controls, read by the daemon. `native` drives macOS app sessions through the macOS helper instead of XCTest; see [Commands](/docs/commands). Unset or `xctest` keeps the runner. The drawn agent pointer adds about 0.3 s to each native click, fill, type, and scroll; `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` turns it off. Restart the daemon after changing either value. |
</file context>
Suggested change
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is how many runner cache keys per platform survive a new build (default 3, `0` keeps all). |
| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is the number of most-recent runner cache keys per platform guaranteed to survive a new build (default 3, `0` disables cleanup); keys used within the last day, locked by a build, or referenced by an active lease may also survive. |

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

Copy link
Copy Markdown
Contributor Author

Pushed 503e977. Live proof below, then the code changes.

Live proof (this head, booted iOS 27.0 simulator)

Isolation: the shared ~/.agent-device is used by other agents, so every command ran with HOME set to a scratch directory. The runner cache root ($HOME/.agent-device/apple-runner/derived/ios-simulator), the runner leases and the daemon state all derive from os.homedir(), so they all moved into the scratch home. ~/Library/Developer in the scratch home is a symlink to the real one, because simctl and Xcode find simulators there; the simulator was a throwaway I created with simctl and deleted afterwards. I did not set AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH (it makes the sweep inert). I used com.apple.Preferences as the app.

  1. Built one real key first: open com.apple.Preferences --platform ios in the empty scratch home produced cache-817d3c95b86bcf90 (167 MB, with metadata). I then copied that tree to cache-1111111111111111 and cache-2222222222222222 to stand in for two real keys from other Xcode or runner versions (same bytes, fresh metadata), and seeded cache-ffffffffffffffff/Build/Products/stub.txt without metadata. So: 2 real keys other than the one being built, AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=3.
  2. Fresh daemon with AGENT_DEVICE_IOS_CLEAN_DERIVED=1, then open com.apple.Preferences --platform ios --session s and snapshot -i --session s.

Before:

$ ls $HOME/.agent-device/apple-runner/derived/ios-simulator
cache-1111111111111111  cache-2222222222222222  cache-817d3c95b86bcf90  cache-ffffffffffffffff

(1) Diagnostics from the daemon request log (paths shortened to <home>):

12:45:54.361 info runner_xctestrun_cache {"action":"clean","reason":"forced_clean","derived":"<home>/.../cache-817d3c95b86bcf90"}
12:45:54.455 warn runner_xctestrun_cache {"action":"rebuild","reason":"cache_metadata_missing", ...}
12:46:01.665 info runner_xctestrun_cache {"action":"build","reason":"built_new","derived":"<home>/.../cache-817d3c95b86bcf90", ...}
12:46:01.743 info runner_xctestrun_cache {"action":"clean","reason":"stale_cache_evicted","derived":"<home>/.../cache-ffffffffffffffff"}

built_new for the real key, then stale_cache_evicted for the stub 78 ms later. (With AGENT_DEVICE_IOS_CLEAN_DERIVED=1 every ensure call cleans and rebuilds the key, so a second forced_clean and built_new pair for cache-817d... follows at 12:46:01.668 and 12:46:08.004. That is the existing behaviour of the env var, not the sweep: the sweep never touches the current key.)

(2) After:

$ ls $HOME/.agent-device/apple-runner/derived/ios-simulator
cache-1111111111111111  cache-2222222222222222  cache-817d3c95b86bcf90

Stub gone, both other real keys and the current key intact.

(3) Snapshot, run while the build and sweep were in flight and again after they finished (open returned Opened: com.apple.Preferences):

$ agent-device snapshot -i --session s
Page: com.apple.Preferences
App: com.apple.Preferences
Snapshot: 19 visible nodes
@e1 [other] "Settings"
@e2 [navigation-bar] "Settings"
@e3 [collection] "com.apple.settings.sidebar.collectionView"

The daemon, runner and simulator were mine and were stopped and deleted afterwards.

Code changes

  • runner-cache-retention.ts: the comment now says a half-deleted key is never a hit and the next build for that key overwrites it.
  • Eviction failures: the empty catches in runner-artifact.ts and runner-cache-retention.ts now emit a warn diagnostic (runner_xctestrun_cache_eviction_failed, with the key and error message); the start path still never fails.
  • listActiveRunnerLeaseArtifacts now filters through classifyRunnerLease (anything but stale, or a stale lease whose runner process is still intact) instead of the inline owner-liveness rule. The existing lease tests pass unchanged.
  • New test: a runner reuses key B between the listing and the lock (its metadata is touched when the first eviction's rm runs); B must survive while the other stale key is evicted. I confirmed it fails when the under-lock recheck is removed.

apple-runner project (70 files, 803 tests), format:check, lint and typecheck pass locally. I did not run the smoke or eager-closure suites; CI covers them.

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

🔵 Needs a closer look

Lease inspection failures can be mistaken for no active leases, allowing deletion of an in-use cache.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (1)

@thymikee

thymikee commented Oct 6, 2026

Copy link
Copy Markdown
Member

The earlier review at 0542d32 asked for live proof, and that is now in place. The quoted daemon log and directory listing from this head match what that review asked for, though I did not rerun the simulator proof myself. The code still needs two changes before merge, and Compatibility & Provenance is red because of it. There are no conflicts.

emitEvictionFailure is exported, but only its own module calls it, so fallow flags it as an unused export. evictStaleRunnerCachesBestEffort also repeats the same runner_xctestrun_cache_eviction_failed emit with the same payload. That gives one diagnostic two writers that can drift apart. Could the retention module be the only emitter of that diagnostic? evictStaleRunnerCaches catches its own listing, stat and per-key failures, so the outer catch in runner-artifact.ts only covers a failed dynamic import. Please drop export from emitEvictionFailure. Then either remove the duplicate emit or give the import failure its own one-line phase. Moving the whole fail-soft wrapper into the retention module would also work.

This also needs a fix before merge, as the open P1 threads below say: the lease filter in runner-lease.ts still fails open, but eviction should treat a lease as unused only on positive proof. listRunnerLeases returns an empty list on any readdir error, and a null start-time read marks a live runner as not intact when the owner is stale. The rule could be that every lease not proven dead counts as in use, and the sweep aborts when leases cannot be listed. classifyOwnerLiveness from host-kit already reads an unreadable start time as alive, so the runner check can reuse it.

The inline threads on the readdir fallback (Copilot, cubic), the stale-owner lease check (Copilot, cubic) and the null start-time probe (cubic P1) still apply. Six docs-wording threads on configuration.md and commands.md also still apply: r4195359969, r4195414306, r4195414322, r4195414334 and r4195414362. Two threads do not apply and can be resolved. r4195414297 does not apply because the stale key is 30 days before the fixed test time, and the real clock is later, so its idle time only grows. r4195414349 does not apply because the empty catch now emits a warning, and the remaining empty returns are intended for a stub with no metadata.

The fallow failure at 503e977 has two parts. The unused export above comes from this PR's changes since 0542d32. The complexity findings in http-server.ts come from main, and this PR does not touch that file. I did not run the apple-runner unit suite, the eager-closure check or fallow locally. The failure attribution comes from the CI job log. I also did not check how deleting a key's derived tree affects a runner that is already running. The next step is to remove the unused export and the duplicate emit so fallow passes on the PR's own findings.

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.

Apple runner cache under ~/.agent-device/apple-runner grows without bound (4.4 GB measured)

3 participants