diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d12996be5a..d3285c464e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -70,7 +70,8 @@ Use `pnpm build:macos-helper:clean` if a Swift cache was created in another work
`pnpm publish` and package-manager pack commands run `prepack`, which first checks synchronized MCP
metadata and then runs `pnpm package:npm`. This is the one completeness-oriented aggregate: it
-builds the TypeScript distribution and all four Apple runner targets, clean-builds the macOS helper,
+builds the TypeScript distribution and all four Apple runner targets (into a scratch directory under
+`.tmp/` that it removes afterwards, not into `~/.agent-device`), clean-builds the macOS helper,
packages the Apple runner source, and rebuilds both Android helper APKs. Any failed build stops
packaging. It deliberately does not stop the worktree's development daemon; use `pnpm rebuild:cli`
when a running daemon needs to pick up a new TypeScript build.
diff --git a/package.json b/package.json
index 040fd12169..5fbf2afe5d 100644
--- a/package.json
+++ b/package.json
@@ -105,6 +105,7 @@
"build:xcuitest:tvos:clean": "pnpm clean:xcuitest:tvos && pnpm build:xcuitest:tvos",
"build:xcuitest:visionos": "AGENT_DEVICE_XCUITEST_PLATFORM=visionos sh ./scripts/build-xcuitest-apple.sh",
"build:xcuitest:visionos:clean": "pnpm clean:xcuitest:visionos && pnpm build:xcuitest:visionos",
+ "package:xcuitest": "node scripts/build-package-xcuitest.mjs",
"build:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/build-android-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper",
"package:android-snapshot-helper": "AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper",
"package:android-snapshot-helper:npm": "rm -rf android/snapshot-helper/dist && AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") android/snapshot-helper/dist",
@@ -118,7 +119,7 @@
"test:macos-helper": "node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts swift test --package-path apple/macos-helper",
"check:macos-helper": "pnpm build:macos-helper && pnpm test:macos-helper",
"prepare:publish-assets": "node scripts/prepare-publish-assets.mjs",
- "build:package": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm prepare:publish-assets",
+ "build:package": "pnpm build && pnpm package:xcuitest && pnpm build:macos-helper:clean && pnpm prepare:publish-assets",
"package:npm": "pnpm build:package && pnpm check:package",
"release:prepare": "rm -rf .tmp/release && pnpm check:mcp-metadata && pnpm build:package && pnpm check:package -- --pack-destination .tmp/release && node scripts/release-workspace.mjs pack",
"release:publish": "node scripts/release-mark-dev.mjs --check-release-version && pnpm release:prepare && node scripts/release-workspace.mjs publish",
diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts
new file mode 100644
index 0000000000..e15f797632
--- /dev/null
+++ b/packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts
@@ -0,0 +1,260 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { afterEach, beforeEach, test, vi } from 'vitest';
+import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo';
+import { appleRunnerTestHost } from '../test-host.ts';
+import { resolveRunnerCacheMetadataPath } from '../runner-cache.ts';
+import { trimRunnerBuildScratch } from '../runner-cache-trim.ts';
+import { ensureXctestrunArtifact } from '../runner-xctestrun.ts';
+import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts';
+import { IOS_SIMULATOR } from './device-fixtures.ts';
+import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts';
+import { mkdtempForTestSync } from './tmp-dir.ts';
+
+const KEY = 'cache-0123456789abcdef';
+
+let base: string;
+let previousDerivedOverride: string | undefined;
+
+beforeEach(() => {
+ base = mkdtempForTestSync('agent-device-runner-trim-');
+ previousDerivedOverride = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
+ delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
+});
+
+afterEach(() => {
+ if (previousDerivedOverride === undefined)
+ delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH;
+ else process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = previousDerivedOverride;
+});
+
+function seedBuiltKey(derived: string): string[] {
+ const products = path.join(derived, 'Build', 'Products');
+ const app = path.join(products, 'Debug-iphonesimulator', 'Runner.app');
+ const xctestrun = path.join(products, 'Runner.xctestrun');
+ fs.mkdirSync(app, { recursive: true });
+ fs.writeFileSync(path.join(app, 'Runner'), 'binary');
+ fs.writeFileSync(xctestrun, '');
+ fs.writeFileSync(resolveRunnerCacheMetadataPath(derived), '{}');
+ for (const scratch of [
+ 'Build/Intermediates.noindex/Runner.build/obj.o',
+ 'Build/Products/Debug-iphonesimulator/Runner.app.dSYM',
+ 'SDKExplicitPrecompiledModules/Foundation.pcm',
+ 'ModuleCache.noindex/session.timestamp',
+ 'Logs/Build/build.xcactivitylog',
+ ]) {
+ fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true });
+ fs.writeFileSync(path.join(derived, scratch), 'scratch');
+ }
+ return [xctestrun, app];
+}
+
+function tree(directory: string, prefix = ''): string[] {
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const relative = path.join(prefix, entry.name);
+ return entry.isDirectory()
+ ? [relative, ...tree(path.join(directory, entry.name), relative)]
+ : [relative];
+ });
+}
+
+test('trimming keeps the products and the metadata file and removes the rest', async () => {
+ const derived = path.join(base, KEY);
+ const protectedPaths = seedBuiltKey(derived);
+
+ const removed = await trimRunnerBuildScratch(derived, protectedPaths, derived);
+
+ assert.deepEqual(removed.sort(), [
+ 'Build/Intermediates.noindex',
+ 'Logs',
+ 'ModuleCache.noindex',
+ 'SDKExplicitPrecompiledModules',
+ ]);
+ assert.deepEqual(
+ tree(derived).sort(),
+ [
+ '.agent-device-runner-cache.json',
+ 'Build',
+ 'Build/Products',
+ 'Build/Products/Debug-iphonesimulator',
+ 'Build/Products/Debug-iphonesimulator/Runner.app',
+ 'Build/Products/Debug-iphonesimulator/Runner.app.dSYM',
+ 'Build/Products/Debug-iphonesimulator/Runner.app/Runner',
+ 'Build/Products/Runner.xctestrun',
+ ].map((entry) => entry.replaceAll('/', path.sep)),
+ );
+});
+
+test('a product outside the cache root leaves the tree untouched', async () => {
+ const derived = path.join(base, KEY);
+ seedBuiltKey(derived);
+ const outside = path.join(base, 'elsewhere.app');
+ fs.mkdirSync(outside);
+ const before = tree(derived);
+
+ assert.deepEqual(await trimRunnerBuildScratch(derived, [outside], derived), []);
+
+ assert.deepEqual(tree(derived), before);
+});
+
+test('a directory that is not the expected key is not trimmed', async () => {
+ const expected = path.join(base, 'managed', KEY);
+ const fixed = path.join(base, 'fixed', KEY);
+ fs.mkdirSync(expected, { recursive: true });
+ const protectedPaths = seedBuiltKey(fixed);
+ const before = tree(fixed);
+
+ assert.deepEqual(await trimRunnerBuildScratch(fixed, protectedPaths, expected), []);
+
+ assert.deepEqual(tree(fixed), before);
+});
+
+test('a symlinked key resolving outside the expected key is not trimmed', async () => {
+ const managedRoot = path.join(base, 'managed');
+ const target = path.join(base, 'target');
+ fs.mkdirSync(path.join(managedRoot, 'other-key'), { recursive: true });
+ const protectedPaths = seedBuiltKey(target);
+ const link = path.join(managedRoot, KEY);
+ fs.symlinkSync(target, link);
+ const before = tree(target);
+
+ assert.deepEqual(
+ await trimRunnerBuildScratch(
+ link,
+ protectedPaths.map((product) => path.join(link, path.relative(target, product))),
+ path.join(managedRoot, 'other-key'),
+ ),
+ [],
+ );
+
+ assert.deepEqual(tree(target), before);
+});
+
+test('a default key that is itself a symlink to an outside directory is not trimmed', async () => {
+ const managedRoot = path.join(base, 'managed');
+ const outside = path.join(base, 'outside');
+ fs.mkdirSync(managedRoot);
+ const protectedPaths = seedBuiltKey(outside);
+ const link = path.join(managedRoot, KEY);
+ fs.symlinkSync(outside, link);
+ const before = tree(outside);
+
+ assert.deepEqual(
+ await trimRunnerBuildScratch(
+ link,
+ protectedPaths.map((product) => path.join(link, path.relative(outside, product))),
+ link,
+ ),
+ [],
+ );
+
+ assert.deepEqual(tree(outside), before);
+});
+
+test('a product that is a symlink keeps the unit it points into', async () => {
+ const derived = path.join(base, KEY);
+ const [xctestrun] = seedBuiltKey(derived);
+ const target = path.join(derived, 'Build', 'Intermediates.noindex', 'Runner.build');
+ const link = path.join(derived, 'Build', 'Products', 'Linked.app');
+ fs.symlinkSync(target, link);
+
+ const removed = await trimRunnerBuildScratch(derived, [xctestrun!, link], derived);
+
+ assert.deepEqual(removed.sort(), [
+ 'Logs',
+ 'ModuleCache.noindex',
+ 'SDKExplicitPrecompiledModules',
+ ]);
+ assert.equal(fs.existsSync(path.join(target, 'obj.o')), true);
+});
+
+function mockRunnerBuild() {
+ resetAllProcessMemosForTests();
+ const projectRoot = mkdtempForTestSync('agent-device-runner-trim-root-');
+ fs.mkdirSync(
+ path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'),
+ { recursive: true },
+ );
+ const runCmdStreaming = vi.fn().mockImplementation(async (_command: string, args: string[]) => {
+ const symroot = args.find((arg) => arg.startsWith('SYMROOT='))!.slice('SYMROOT='.length);
+ const derived = path.dirname(path.dirname(symroot));
+ await seedRunnerProductBundle(path.join(symroot, 'Debug-iphonesimulator', 'Runner.app'));
+ fs.writeFileSync(
+ path.join(symroot, 'Runner_iphonesimulator27.0-arm64.xctestrun'),
+ `
+ProductPaths
+__TESTROOT__/Debug-iphonesimulator/Runner.app
+`,
+ );
+ for (const scratch of [
+ 'Build/Intermediates.noindex/obj.o',
+ 'Logs/build.log',
+ 'ModuleCache.noindex/m',
+ ]) {
+ fs.mkdirSync(path.dirname(path.join(derived, scratch)), { recursive: true });
+ fs.writeFileSync(path.join(derived, scratch), 'scratch');
+ }
+ return { exitCode: 0, stdout: '', stderr: '' };
+ });
+ appleRunnerTestHost.update({
+ runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult),
+ runCmdStreaming,
+ findProjectRoot: () => projectRoot,
+ readVersion: () => '0.0.0-test',
+ });
+
+ return { runCmdStreaming };
+}
+
+test('a runner build trims its key, and the next start reuses the products without rebuilding', async () => {
+ const { runCmdStreaming } = mockRunnerBuild();
+
+ const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
+
+ assert.equal(built.artifact, 'rebuilt');
+ assert.deepEqual(fs.readdirSync(built.derived).sort(), [
+ '.agent-device-runner-cache.json',
+ 'Build',
+ ]);
+ assert.deepEqual(fs.readdirSync(path.join(built.derived, 'Build')), ['Products']);
+
+ const reused = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
+
+ assert.equal(reused.artifact, 'valid');
+ assert.equal(reused.xctestrunPath, built.xctestrunPath);
+ assert.equal(runCmdStreaming.mock.calls.length, 1);
+});
+
+test('a derived path override named like a cache key keeps its build scratch', async () => {
+ mockRunnerBuild();
+ const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
+ process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join(
+ base,
+ 'fixed',
+ path.basename(managed.derived),
+ );
+
+ const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
+
+ assert.equal(built.artifact, 'rebuilt');
+ assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH);
+ assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true);
+ assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true);
+});
+
+test('a derived path override inside the managed root keeps its build scratch', async () => {
+ mockRunnerBuild();
+ const managed = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
+ process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = path.join(
+ path.dirname(managed.derived),
+ 'development',
+ );
+
+ const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {});
+
+ assert.equal(built.artifact, 'rebuilt');
+ assert.equal(built.derived, process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH);
+ assert.equal(fs.existsSync(path.join(built.derived, 'Build', 'Intermediates.noindex')), true);
+ assert.equal(fs.existsSync(path.join(built.derived, 'Logs')), true);
+});
diff --git a/packages/platform-apple/src/runner/runner-artifact-manifest.ts b/packages/platform-apple/src/runner/runner-artifact-manifest.ts
index b9a8486291..aa8b76e181 100644
--- a/packages/platform-apple/src/runner/runner-artifact-manifest.ts
+++ b/packages/platform-apple/src/runner/runner-artifact-manifest.ts
@@ -657,7 +657,7 @@ function isNonEmptyArray- (value: unknown): value is Item[] {
return Array.isArray(value) && value.length > 0;
}
-function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean {
+export function isPathInsideDirectory(targetPath: string, directoryPath: string): boolean {
const relativePath = path.relative(path.resolve(directoryPath), path.resolve(targetPath));
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}
diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts
index d13be1de36..2db0996401 100644
--- a/packages/platform-apple/src/runner/runner-artifact.ts
+++ b/packages/platform-apple/src/runner/runner-artifact.ts
@@ -60,7 +60,7 @@ import {
resolveRunnerBuildDestination,
resolveRunnerXctestrunHints,
} from './apple-runner-platform.ts';
-import { resolveRunnerCacheKey } from './runner-cache-metadata.ts';
+import { resolveRunnerCacheKey, resolveRunnerKeyedDerivedPath } from './runner-cache-metadata.ts';
import { resolveAppleRunnerProjectPath } from './runner-source.ts';
export { prepareXctestrunWithEnv } from './runner-artifact-env.ts';
@@ -689,6 +689,11 @@ async function buildXctestrunArtifact(params: {
),
derived,
);
+ await trimRunnerBuildScratchBestEffort(
+ resolveRunnerKeyedDerivedPath(device, expectedCacheMetadata),
+ derived,
+ [built, ...builtProductPaths],
+ );
emitRunnerXctestrunDecision('build', 'built_new', {
derived,
xctestrunPath: built,
@@ -705,6 +710,26 @@ async function buildXctestrunArtifact(params: {
};
}
+/** Runs under the cache lock, so no rebuild or reuse of this key sees the tree mid-trim. */
+async function trimRunnerBuildScratchBestEffort(
+ expectedKeyPath: string,
+ derived: string,
+ protectedPaths: readonly string[],
+): Promise {
+ try {
+ const { trimRunnerBuildScratch } = await import('./runner-cache-trim.ts');
+ const removed = await trimRunnerBuildScratch(derived, protectedPaths, expectedKeyPath);
+ if (removed.length > 0)
+ emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived });
+ } catch (error) {
+ emitDiagnostic({
+ level: 'warn',
+ phase: 'runner_xctestrun_cache_trim_failed',
+ data: { derived, error: error instanceof Error ? error.message : String(error) },
+ });
+ }
+}
+
async function tryReuseExistingXctestrun(
device: DeviceInfo,
derived: string,
diff --git a/packages/platform-apple/src/runner/runner-cache-metadata.ts b/packages/platform-apple/src/runner/runner-cache-metadata.ts
index 79954c917f..66e0546a25 100644
--- a/packages/platform-apple/src/runner/runner-cache-metadata.ts
+++ b/packages/platform-apple/src/runner/runner-cache-metadata.ts
@@ -486,9 +486,14 @@ export function resolveRunnerDerivedPath(
if (override) {
return path.resolve(override);
}
- const cacheKey = resolveRunnerCacheKey(metadata);
- const base = resolveRunnerDerivedBasePath(device);
- return path.join(base, cacheKey);
+ return resolveRunnerKeyedDerivedPath(device, metadata);
+}
+
+export function resolveRunnerKeyedDerivedPath(
+ device: DeviceInfo,
+ metadata: RunnerXctestrunCacheMetadata,
+): string {
+ return path.join(resolveRunnerDerivedBasePath(device), resolveRunnerCacheKey(metadata));
}
function resolveRunnerDerivedBasePath(device: DeviceInfo): string {
diff --git a/packages/platform-apple/src/runner/runner-cache-trim.ts b/packages/platform-apple/src/runner/runner-cache-trim.ts
new file mode 100644
index 0000000000..a5333b25b8
--- /dev/null
+++ b/packages/platform-apple/src/runner/runner-cache-trim.ts
@@ -0,0 +1,93 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { isPathInsideDirectory } from './runner-artifact-manifest.ts';
+import { RUNNER_CACHE_METADATA_FILE } from './runner-cache-metadata.ts';
+
+/**
+ * Removes the build scratch from a keyed runner cache after a successful build: intermediates,
+ * precompiled and module caches, logs. Reuse and launch read only the products the manifest
+ * certifies and the metadata file, and a key that fails certification is deleted and rebuilt from
+ * scratch, so the scratch is never read again; a runner source or Xcode change mints a new key
+ * instead of building into this one.
+ *
+ * Returns the removed entries relative to `derived`. Nothing is removed unless `derived` resolves,
+ * by real path, to a real directory named like `expectedKeyPath` that is a direct child of the real
+ * parent of `expectedKeyPath` (the key this build gets with no path override), or when a product
+ * lies outside `derived`. A key entry that is itself a symlink is never trimmed.
+ */
+export async function trimRunnerBuildScratch(
+ derived: string,
+ protectedPaths: readonly string[],
+ expectedKeyPath: string,
+): Promise {
+ if (!isExpectedKey(derived, expectedKeyPath)) return [];
+ const kept = resolveKeptPaths(derived, protectedPaths);
+ if (!kept) return [];
+ return await trimDirectory(derived, derived, kept);
+}
+
+function isExpectedKey(derived: string, expectedKeyPath: string): boolean {
+ try {
+ const canonicalKey = path.join(
+ fs.realpathSync(path.dirname(expectedKeyPath)),
+ path.basename(expectedKeyPath),
+ );
+ return fs.realpathSync(derived) === canonicalKey;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * `Build/` is the unit kept under `Build`, so the products survive and their siblings do not.
+ * A product that is missing or lies outside the key makes the trim a no-op. A product that is a
+ * symlink keeps its target's unit as well.
+ */
+function resolveKeptPaths(derived: string, protectedPaths: readonly string[]): Set | null {
+ const kept = new Set([RUNNER_CACHE_METADATA_FILE]);
+ try {
+ const realDerived = fs.realpathSync(derived);
+ for (const protectedPath of protectedPaths) {
+ const realProtected = fs.realpathSync(protectedPath);
+ if (
+ !isPathInsideDirectory(protectedPath, derived) ||
+ !isPathInsideDirectory(realProtected, realDerived)
+ ) {
+ return null;
+ }
+ kept.add(keptUnit(path.relative(derived, protectedPath)));
+ kept.add(keptUnit(path.relative(realDerived, realProtected)));
+ }
+ } catch {
+ return null;
+ }
+ return kept;
+}
+
+function keptUnit(relative: string): string {
+ const segments = relative.split(path.sep);
+ return segments.slice(0, segments[0] === 'Build' ? 2 : 1).join(path.sep);
+}
+
+async function trimDirectory(
+ root: string,
+ directory: string,
+ kept: ReadonlySet,
+): Promise {
+ const removed: string[] = [];
+ for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ const relative = path.relative(root, entryPath);
+ if (kept.has(relative)) continue;
+ if (
+ entry.isDirectory() &&
+ [...kept].some((keep) => keep.startsWith(`${relative}${path.sep}`))
+ ) {
+ removed.push(...(await trimDirectory(root, entryPath, kept)));
+ continue;
+ }
+ await fs.promises.rm(entryPath, { recursive: true, force: true });
+ removed.push(relative);
+ }
+ return removed;
+}
diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts
index b69917752c..e23a69b875 100644
--- a/packages/platform-apple/src/runner/runner-cache.ts
+++ b/packages/platform-apple/src/runner/runner-cache.ts
@@ -382,7 +382,8 @@ export function emitRunnerXctestrunDecision(
| 'external_xctestrun'
| 'external_bad_artifact'
| 'uncertifiable_products'
- | 'stale_cache_evicted',
+ | 'stale_cache_evicted'
+ | 'build_scratch_trimmed',
data: Record,
): void {
emitDiagnostic({
diff --git a/scripts/__tests__/build-package-xcuitest.test.ts b/scripts/__tests__/build-package-xcuitest.test.ts
new file mode 100644
index 0000000000..eb3ad072ed
--- /dev/null
+++ b/scripts/__tests__/build-package-xcuitest.test.ts
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { test } from 'vitest';
+import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts';
+import { buildPackageXcuitest } from '../build-package-xcuitest.mjs';
+
+type Build = { root: string; platform: string; derivedPath: string };
+
+test('builds every platform into a scratch directory under the repo and removes it', () => {
+ const root = mkdtempForTestSync('agent-device-package-xcuitest-');
+ const seen: Build[] = [];
+
+ buildPackageXcuitest({
+ root,
+ build: (build: Build) => {
+ seen.push(build);
+ fs.mkdirSync(path.join(build.derivedPath, 'Build', 'Products'), { recursive: true });
+ },
+ });
+
+ assert.deepEqual(
+ seen.map(({ platform }) => platform),
+ ['ios', 'macos', 'tvos', 'visionos'],
+ );
+ for (const { derivedPath } of seen) {
+ assert.equal(path.dirname(derivedPath), path.join(root, '.tmp', 'package-xcuitest'));
+ assert.equal(fs.existsSync(derivedPath), false);
+ }
+ assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []);
+});
+
+test('removes the scratch directory when a platform build fails', () => {
+ const root = mkdtempForTestSync('agent-device-package-xcuitest-');
+
+ assert.throws(
+ () =>
+ buildPackageXcuitest({
+ root,
+ platforms: ['ios', 'macos'],
+ build: ({ platform, derivedPath }: Build) => {
+ fs.mkdirSync(derivedPath, { recursive: true });
+ if (platform === 'macos') throw new Error('xcodebuild failed');
+ },
+ }),
+ /xcodebuild failed/,
+ );
+
+ assert.deepEqual(fs.readdirSync(path.join(root, '.tmp')), []);
+});
+
+test('starts from an empty scratch directory when an interrupted run left one behind', () => {
+ const root = mkdtempForTestSync('agent-device-package-xcuitest-');
+ const leftover = path.join(root, '.tmp', 'package-xcuitest', 'ios', 'Build');
+ fs.mkdirSync(leftover, { recursive: true });
+ let leftoverSeen = true;
+
+ buildPackageXcuitest({
+ root,
+ platforms: ['ios'],
+ build: ({ derivedPath }: Build) => {
+ leftoverSeen = fs.existsSync(derivedPath);
+ },
+ });
+
+ assert.equal(leftoverSeen, false);
+});
diff --git a/scripts/build-package-xcuitest.mjs b/scripts/build-package-xcuitest.mjs
new file mode 100644
index 0000000000..0bba87fce9
--- /dev/null
+++ b/scripts/build-package-xcuitest.mjs
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const PLATFORMS = ['ios', 'macos', 'tvos', 'visionos'];
+
+/**
+ * Compiles the Apple runner for every platform the package supports, into a scratch directory
+ * under `/.tmp` that is removed before and after the builds, so an interrupted run leaves
+ * nothing past the next one. The package ships the runner source and the daemon builds into its
+ * own keyed cache, so nothing reads these products; building them at the default
+ * `~/.agent-device/apple-runner/derived` location only left about 1 GB there per machine.
+ */
+export function buildPackageXcuitest(options = {}) {
+ const root = path.resolve(options.root ?? process.cwd());
+ const platforms = options.platforms ?? PLATFORMS;
+ const build = options.build ?? runBuildScript;
+ const scratch = path.join(root, '.tmp', 'package-xcuitest');
+ fs.rmSync(scratch, { recursive: true, force: true });
+ fs.mkdirSync(scratch, { recursive: true });
+ try {
+ for (const platform of platforms) {
+ build({ root, platform, derivedPath: path.join(scratch, platform) });
+ }
+ } finally {
+ fs.rmSync(scratch, { recursive: true, force: true });
+ }
+}
+
+function runBuildScript({ root, platform, derivedPath }) {
+ execFileSync('sh', [path.join('scripts', 'build-xcuitest-apple.sh')], {
+ cwd: root,
+ env: {
+ ...process.env,
+ AGENT_DEVICE_XCUITEST_PLATFORM: platform,
+ AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: derivedPath,
+ },
+ stdio: 'inherit',
+ });
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ buildPackageXcuitest();
+ } catch (error) {
+ process.exitCode = typeof error?.status === 'number' ? error.status : 1;
+ if (typeof error?.status !== 'number') console.error(error);
+ }
+}
diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts
index dba14093c7..7f35bea71e 100644
--- a/src/__tests__/npm-package-scripts.test.ts
+++ b/src/__tests__/npm-package-scripts.test.ts
@@ -50,10 +50,7 @@ test('Fallow exposes one changed-code gate and an explicit full-tree audit', ()
test('the npm package build covers every package-owned output before verification', () => {
assert.deepEqual(script('build:package').split(' && '), [
'pnpm build',
- 'pnpm build:xcuitest:ios',
- 'pnpm build:xcuitest:macos',
- 'pnpm build:xcuitest:tvos',
- 'pnpm build:xcuitest:visionos',
+ 'pnpm package:xcuitest',
'pnpm build:macos-helper:clean',
'pnpm prepare:publish-assets',
]);
diff --git a/vitest.config.ts b/vitest.config.ts
index 1bcd155b27..b7b676fa96 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -153,6 +153,7 @@ export default defineConfig({
// Publish preparation spawns only fixture-owned scripts and proves both Android
// helper families are rebuilt through the shared release/size-report owner.
'scripts/__tests__/prepare-publish-assets.test.ts',
+ 'scripts/__tests__/build-package-xcuitest.test.ts',
// The packager's Swift comment scanner: pure string transform, and the only place a
// literal that looks like a comment (a URL, a raw or multi-line literal) is proven
// to survive packaging before the npm package ships unbuildable Swift.
diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md
index b58061ba11..b5be1a80ca 100644
--- a/website/docs/docs/commands.md
+++ b/website/docs/docs/commands.md
@@ -281,6 +281,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; one iOS simulator key measured 165.7 MB before the trim and 5.9 MB after. 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. Only the key this build gets with no path override is trimmed, compared by resolved real path; a `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` pointing anywhere else, including inside the managed runner cache, 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.
- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived//`, where the folder names the platform and device kind (`ios-simulator`, `ios-device`, `tvos-simulator`, `tvos-device`, `macos`, `visionos-simulator`, `visionos-device`). After a build, Agent Device deletes, on a best-effort basis and without delaying the start, keys beside the new one in the same folder that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a runner lease not proven dead points at or a build is holding. A key that cannot be deleted is left in place and reported as a `runner_xctestrun_cache_eviction_failed` warning diagnostic. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=` 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.