# Parallel xcodebuild for agents: DerivedData and Simulators

Canonical URL: https://macswarm.com/blog/parallel-xcodebuild-coding-agents/
Last reviewed: 2026-10-26

> Avoid DerivedData collisions in parallel xcodebuild runs. Measured macOS examples cover worktrees, SwiftPM checkouts, build limits and result bundles.

Avoid overlapping builds that share a DerivedData path. Give each coding agent a worktree to isolate edits, and verify that build outputs and package checkouts are separate. In three batches of a synthetic macOS package, two and three concurrent builds had similar throughput; the third increased median build latency by 51%.

## What breaks when several agents run xcodebuild on one Mac?

Concurrent `xcodebuild` invocations need separate mutable build state. In a synthetic macOS SwiftPM project tested with Xcode 26.6, overlapping builds using one DerivedData path failed with a database error; separate DerivedData paths let both builds finish. Git worktrees isolate source edits, and the two worktrees checked here received different default DerivedData directories, so explicit paths matter mostly when a wrapper forces agents into one shared directory. Swift package checkouts need separate attention: eleven of fifteen concurrent resolves into an empty shared checkout directory failed, while fifteen of fifteen into a seeded directory succeeded, and different dependency requirements still switched that shared checkout between versions. Use separate mutable package directories and serialize commands that share one worktree. These observations come from one synthetic package on one working M2 Pro laptop. They are failure cases to check, not a universal concurrency limit for iOS apps or hosted Macs. Every script and raw timing is published; see [Reproduce it](#reproduce-it).

## Why does parallel xcodebuild report "database is locked"?

The build system keeps its state in `DerivedData/Build/Intermediates.noindex/XCBuildData/build.db`, and a running build holds it. From the lab directory after `./setup.sh`, with no `dd/shared` folder yet, we started two clean builds of the macOS test package five seconds apart:

```bash
cd wt/agent-1
xcodebuild -scheme XcbLab -destination 'platform=macOS,arch=arm64' -derivedDataPath ../../dd/shared build &
sleep 5
xcodebuild -scheme XcbLab -destination 'platform=macOS,arch=arm64' -derivedDataPath ../../dd/shared build
```

One build failed with exit status 65:

```text
error: unable to attach DB: error: accessing build database ".../dd/shared/Build/Intermediates.noindex/XCBuildData/build.db": database is locked Possibly there are two concurrent builds running in the same filesystem location.
** BUILD FAILED **
```

When both started at the same instant, the failing build reported `unable to attach DB: unable to initialize database (disk I/O error)` instead. Each row below is a single demonstration, not a median:

| Case | First build | Second build |
| --- | --- | --- |
| One checkout, one DerivedData, second starts 5 s later | 38.6 s, exit 0 | failed after 6.7 s, exit 65 |
| One checkout, one DerivedData, both at once | 40.2 s, exit 0 | failed after 2.7 s, exit 65 |
| Two worktrees, one shared `-derivedDataPath`, 5 s apart | 41.5 s, exit 0 | failed after 8.4 s, exit 65 |
| One checkout, two separate DerivedData paths | 74.3 s, exit 0 | 74.6 s, exit 0 |

In these runs neither build waited or retried. The loser is not always the later build: rerunning the published script on a smaller package with a one-second gap, the earlier build failed and the later one finished. The message identifies concurrent builds in the same location, but not which process or agent owns the competing build.

## Do git worktrees need separate DerivedData?

Not to avoid this error by default. Xcode names the default DerivedData folder after the project plus a suffix that depends on its location: two worktrees with the same folder name, `a/XcbLab` and `b/XcbLab`, got `XcbLab-bdiyjwui…` and `XcbLab-caqqqlfj…` under `~/Library/Developer/Xcode/DerivedData`. Collisions come from two agents working in one checkout, or from a wrapper that passes every agent the same absolute `-derivedDataPath`. Explicit per-agent paths make that impossible and make cleanup obvious. A [git worktree](https://git-scm.com/docs/git-worktree) gives each agent its own working directory and branch without a second clone. These commands target a macOS scheme:

```bash
# once per agent
git -C ~/src/MyApp worktree add ~/agents/agent-2/src -b agent-2

# every build that agent runs (macOS destination)
cd ~/agents/agent-2/src
xcodebuild -scheme MyApp -destination 'platform=macOS,arch=arm64' \
  -derivedDataPath ~/agents/agent-2/DerivedData build

# when the agent is done: leave the worktree first; removal needs a clean worktree and keeps the branch
cd ~ && git -C ~/src/MyApp worktree remove ~/agents/agent-2/src && rm -rf ~/agents/agent-2/DerivedData
```

`-destination 'platform=macOS'` alone matched five destinations on this Mac and `xcodebuild` warned that it used the first; `arch=arm64` removes the ambiguity.

For an iOS app, select the workspace or project and a shared scheme explicitly. A compile-only build can use `-destination 'generic/platform=iOS Simulator'`; tests need a concrete Simulator, addressed by UDID as in the Simulator section below. We could not run either form: without the iOS platform component installed, `xcodebuild` exited with status 70 because no destination matched.

Worktrees separate agents' edits. They do not stop one agent from starting a build and a test in the same worktree at once, which collides on that worktree's DerivedData just as two agents would, and they do not freeze files that the agent edits while its own build reads them. The rule we use: one build or package mutation at a time per worktree and DerivedData path, with any machine-wide build limit as an additional constraint. [`helpers.sh`](https://macswarm.com/lab/parallel-xcodebuild/helpers.sh) has a `with_worktree_lock` function for the first part.

Keep each worktree's DerivedData for as long as the agent lives; it keeps incremental reuse available. A clean build of the test package took 38.4 s. With DerivedData kept, a no-op build took 2.4 s, and builds after a timestamp-only `touch` of one file took 4.2 s, or 3.4 s for a file in the lowest module. We did not measure real source edits. One built DerivedData folder for this package was 619 MB.

## Can concurrent builds share Swift package checkouts?

By default `xcodebuild` keeps package state in `DerivedData/SourcePackages`: bare repositories, one working copy per package and a `workspace-state.json`. So each agent with its own DerivedData clones every package again. All four flags below appear in `xcodebuild -help` for Xcode 26.6. Our test dependency was a local git repository with tags 1.0.0 and 1.1.0, referenced through a `file://` URL, so nothing was downloaded.

| Flag | Help text, paraphrased | What we saw |
| --- | --- | --- |
| `-clonedSourcePackagesDirPath PATH` | Where remote packages are fetched or expected | `-resolvePackageDependencies` put the whole `SourcePackages` content there and left only logs in DerivedData |
| `-packageCachePath` | Path of caches used for package support | Stayed empty for the local-git dependency; remote packages not tested |
| `-onlyUsePackageVersionsFromResolvedFile` | Do not resolve to versions other than those in `Package.resolved` | Exit 74 on a missing or stale `Package.resolved` |
| `-disableAutomaticPackageResolution` | Same help text as the flag above | Same behaviour in our test |

Sharing one `-clonedSourcePackagesDirPath` between agents is tempting. We ran three resolves at once into one empty shared folder, each from its own worktree and DerivedData, five times. Eleven of fifteen failed with exit status 74 and no round had more than one success; a later run of the published script failed ten of fifteen. The errors varied with timing:

```text
xcodebuild: error: Could not resolve package dependencies:
  error: input file '$SPM/clones-shared/checkouts/LabUtils/Package.swift' was modified during the build
```

Other attempts failed while cloning, with git reporting `cannot copy '…/templates/info/exclude' … File exists`, or with a SwiftPM `InternalError` saying the repository folder `already exists`. After one resolve seeded the shared folder, fifteen of fifteen concurrent resolves succeeded.

The shared `checkouts` folder still holds one working copy per package. After we changed agent 2's manifest requirement to 1.1.0, its resolve updated its `Package.resolved` and switched the shared checkout to 1.1.0; agent 1, still pinned to 1.0.0, switched it back on its next resolve. That is a finding about mutable checkout state, not proof that every download or repository cache is unsafe to share. Strict resolution rejected the deliberately stale resolved file instead:

```text
xcodebuild: error: Could not resolve package dependencies:
  an out-of-date resolved file was detected at $SPM/wt-3/Package.resolved, which is not allowed when automatic dependency resolution is disabled; please make sure to update the file to reflect the changes in dependencies. Running resolver because requirements have changed.
```

Commit the top-level project's resolved file and review manifest and lockfile changes together; Swift.org's [resolving and updating dependencies](https://docs.swift.org/latest/documentation/packagemanagerdocs/resolvingpackageversions/) explains that the file pins versions only for the top-level project. Per agent, on a macOS scheme:

```bash
xcodebuild -scheme MyApp -destination 'platform=macOS,arch=arm64' \
  -derivedDataPath ~/agents/agent-2/DerivedData \
  -clonedSourcePackagesDirPath ~/agents/agent-2/SourcePackages \
  -onlyUsePackageVersionsFromResolvedFile build
```

## How does build time scale with concurrent builds?

Isolation stops the failures. It does not add CPU. We ran one, two and three clean builds at once, each in its own worktree and DerivedData, in three batches per setting. Medians, with the range across all builds:

| Clean builds at once | Wall time per build | Compared with one | Builds per minute | Peak swift-frontend processes | Median sampled peak of summed RSS |
| --- | --- | --- | --- | --- | --- |
| 1 | 38.4 s (35.4–43.3) | 1.00× | 1.56 | 12 | 3,243 MiB |
| 2 | 68.0 s (66.0–71.2) | 1.77× | 1.75 | 24 | 5,110 MiB |
| 3 | 102.5 s (98.0–103.1) | 2.67× | 1.75 | 36 | 6,315 MiB |

Throughput uses batch medians: 1.749 builds per minute with two builds and 1.754 with three. In three batches of this synthetic workload, two and three simultaneous builds both rounded to 1.75 builds per minute, while each build took 51% longer with three. This does not establish the best concurrency limit for other workloads or machines. Each build peaked at 12 compiler processes, one per core here. Our hypothesis for the gain from a second build is that one build's module dependency graph leaves cores idle at times; the experiment does not isolate that. `xcodebuild -jobs NUMBER` sets the maximum number of concurrent build operations per build; we did not test it as an alternative.

Short builds slow down too. For our timestamp-only incremental case, median latency rose from 4.2 s alone to 6.1 s beside one clean build and 10.5 s beside two.

## What trade-offs does a build-slot limit introduce?

Calculated estimates, not timed queue runs: three agents request a clean build at the same moment, and we combine the separate batch medians above. The estimates exclude polling and dispatch overhead and assume service times stay at their medians.

| Policy | Results arrive at | Mean completion time | Last result |
| --- | --- | --- | --- |
| All three at once | 102.6, 102.6, 102.6 s | 102.6 s | 102.6 s |
| Two slots | 68.6, 68.6, 107.0 s | 81.4 s | 107.0 s |
| One slot | 38.4, 76.8, 115.2 s | 76.8 s | 115.2 s |

For this burst, one slot gives the lowest mean, two slots come close while finishing all three sooner, and running everything at once has the earliest last result. Short builds change the picture. By the same arithmetic, a 4-second incremental build that arrives 10 s into a clean build waits about 28 s behind a single slot, while with a second slot free the measured build took 6.1 s. If both slots run clean builds, it waits up to about a minute; with no limit it finished in 10.5 s. A two-slot limit bounds the number of admitted builds, not the machine's total memory use.

A small admission helper fits in a shell function. macOS ships `lockf`, which locks a file descriptor and, with `-t 0`, exits with 75 when the lock is taken (`man lockf`). This version from [`helpers.sh`](https://macswarm.com/lab/parallel-xcodebuild/helpers.sh) keeps lock contention separate from the command's own exit status. We tested success, contention and command failure under bash and zsh, with and without `set -e`:

```bash
build_slot() {   # build_slot CMD...  runs CMD in one of XCB_SLOTS (default 2) slots; waits while all are busy
  local slots="${XCB_SLOTS:-2}" i rc st
  case "$slots" in ''|*[!0-9]*) slots=0 ;; esac
  [ "$slots" -gt 0 ] || { echo "build_slot: XCB_SLOTS must be a positive integer" >&2; return 2; }
  mkdir -p "$XCB_LOCK_DIR" || return 2
  while :; do
    i=1
    while [ "$i" -le "$slots" ]; do
      exec 9>>"$XCB_LOCK_DIR/slot-$i.lock" || return 2
      if lockf -s -t 0 9; then
        if "$@" 9>&-; then rc=0; else rc=$?; fi    # CMD runs without the lock descriptor
        exec 9>&-                                    # closing it frees the slot
        return "$rc"
      else
        st=$?
        exec 9>&-
        [ "$st" -eq 75 ] || { echo "build_slot: lockf failed ($st)" >&2; return 2; }
      fi
      i=$((i + 1))
    done
    sleep 2
  done
}

```

Usage, after downloading `helpers.sh`, for a macOS scheme:

```bash
. ./helpers.sh    # with_worktree_lock, build_slot, with_simulator; locks live in ${TMPDIR:-/tmp}/xcb-locks
with_worktree_lock ~/agents/agent-2/src build_slot xcodebuild -scheme MyApp \
  -destination 'platform=macOS,arch=arm64' -derivedDataPath ~/agents/agent-2/DerivedData build
```

It is an illustration of admission control: waiting callers poll every two seconds and are not served in arrival order. Take locks in one order, worktree first and build slot last, to avoid deadlocks.

## How do you give each agent its own Simulator? (Documented, not measured here)

This Mac had no iOS Simulator runtime, and we did not download one. Nothing in this section ran against a real device. The commands follow `xcrun simctl help <command>` in Xcode 26.6, which Apple's [command-line tool reference](https://developer.apple.com/documentation/xcode/xcode-command-line-tool-reference) names as the documentation for `simctl`, and `man xcodebuild`. The lease functions in [`sim-pool.sh`](https://macswarm.com/lab/parallel-xcodebuild/sim-pool.sh) were checked with stand-in `xcrun` and `xcodebuild` functions only.

- **Address devices by UDID.** `simctl` help says that with several devices booted, the name `booted` makes it choose one of them, so one agent can install onto or erase another agent's device. The `all` forms of `shutdown` and `erase` hit every device in the selected CoreSimulator device set.
- **Pin the pool.** Create the devices once, with an explicit runtime identifier from `xcrun simctl list runtimes`, and keep their UDIDs in one file. To copy a configured device, `xcrun simctl clone <template-udid> <name>`; shut the template down first as a preparation step, which we could not verify as required.
- **Hold the lease from boot to reset.** Boot and wait with `xcrun simctl bootstatus "$SIM_UDID" -b`, run the tests, capture what you need, then `xcrun simctl shutdown` and `xcrun simctl erase` before releasing the device. Quarantine a device whose erase fails. A cheaper reset, `xcrun simctl uninstall "$SIM_UDID" "$APP_BUNDLE_ID"` plus `xcrun simctl privacy "$SIM_UDID" reset all`, leaves other state behind; we have no boot timings to weigh that trade.
- **Turn off parallel testing for a leased device.** Apple's [Xcode 10 release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-10-release-notes) say that when testing in parallel on Simulator, each runner process executes on a separate clone of the selected simulator, outside the pool's accounting. Parallel testing is a scheme option ([Apple: Select other run options for your tests](https://help.apple.com/xcode/mac/current/en.lproj/dev6061748b5.html)); `-parallel-testing-enabled NO` overrides it.
- **Screenshots.** Use test attachments for screenshots taken during tests. A later `simctl io … screenshot` shows the device's current screen.

Usage, after sourcing both files:

```bash
. ./helpers.sh && . ./sim-pool.sh
export SIM_POOL_DIR="$HOME/agents/sim-pool"
make_sim_pool com.apple.CoreSimulator.SimRuntime.iOS-26-5 com.apple.CoreSimulator.SimDeviceType.iPhone-17 3   # once
with_simulator test_on_leased_simulator ~/agents/agent-2 -workspace MyApp.xcworkspace -scheme MyApp
```

Inside the lease, the test command addresses the device by UDID and keeps its exit status even if the screenshot or the reset fails:

```bash
if xcodebuild test "$@" \
    -destination "platform=iOS Simulator,id=$SIM_UDID" \
    -parallel-testing-enabled NO \
    -derivedDataPath "$agent/DerivedData" \
    -resultBundlePath "$run/test.xcresult" > "$run/xcodebuild.log" 2>&1; then rc=0; else rc=$?; fi
```

## How should agents read build and test results?

Give every run its own `-resultBundlePath`. A second run with an existing path exited with status 64 before building:

```text
xcodebuild: error: Existing file at -resultBundlePath ".../results/agent-1/test-1.xcresult"
```

Seconds-based names can repeat during quick retries, so add `uuidgen`. Keep the `xcodebuild` exit status and console log as well: when setup fails, there may be no usable bundle. `xcresulttool` in Xcode 26.6 reads bundles as JSON with `get test-results summary`, `get test-results tests`, `get build-results` and `get log`. A checked extraction, from [`xcresult-demo.sh`](https://macswarm.com/lab/parallel-xcodebuild/xcresult-demo.sh), on a macOS test run with one deliberate failure:

```bash
set -o pipefail
xcrun xcresulttool get test-results summary --path "$run/test.xcresult" \
  | jq -r '"\(.result): \(.passedTests) passed, \(.failedTests) failed",
           (.testFailures[] | "\(.testIdentifierString): \(.failureText)")' \
  || echo "could not read $run/test.xcresult; see $run/xcodebuild.log" >&2
```

```text
Failed: 2 passed, 1 failed
DemoTests/testDeliberateFailure(): XCTAssertEqual failed: ("8") is not equal to ("9") - deliberate failure for the xcresult demo
```

Plan disk for bundles. Here a build-only bundle was 28 KB and a passing test run 160 KB, while the run with the failing test produced 473 MB, and 471 MB when rerun; we did not identify what fills it. Keep failed bundles until investigation is complete, and expire older ones under an explicit disk budget.

## How we measured

- MacBook Pro, Apple M2 Pro, 12 cores (`sysctl -n hw.ncpu hw.perflevel0.physicalcpu hw.perflevel1.physicalcpu` returned 12, 8, 4), 16 GB, macOS 26.3, Xcode 26.6 (17F113), Swift 6.3.3. No Simulator runtimes.
- A generated SwiftPM package: five library targets in a dependency graph with Network and Storage as sibling targets, plus one executable; 501 Swift files, 47,709 lines of protocols with associated types, generic structs with conditional conformances and Codable models. Debug configuration. Nothing downloaded.
- One `git worktree` per simulated agent. Every timed build: `xcodebuild -scheme XcbLab -destination 'platform=macOS,arch=arm64' -derivedDataPath <per-agent path> build`. Clean builds started with DerivedData deleted; incremental builds kept it and ran after `touch` on one file.
- Three batches per setting, always in the order one, two, three builds; medians reported. A sampler read `ps` once a second. In the published rows it attributed CPU and memory by process name across the Mac, not by process tree; the published runner now uses the process tree. `ps` %CPU is a decaying average, and summed RSS is not total memory demand.
- A working Mac with other sessions running and 21.9 GB of swap already in use; pageouts were not recorded in these runs. Each batch waited for at least 65% idle CPU, and a batch was to be repeated if other processes averaged more than 350 summed %CPU; none was. A first attempt was abandoned when a video render from another session took about 7.5 cores.
- Limits: one laptop, one synthetic package, no iOS build. Real apps with resources, more modules or different build settings will behave differently.

## Reproduce it

1. Download the files listed in the [lab README](https://macswarm.com/lab/parallel-xcodebuild/README.txt) into an empty directory and make the scripts executable. You need Xcode, `python3`, `git` and, for the result demo, `jq`.
2. Run `./setup.sh`. It generates the same 501-file package with [`gen.py`](https://macswarm.com/lab/parallel-xcodebuild/gen.py) and adds three worktrees.
3. Run `./collide.sh` for the DerivedData collision. Expect one exit 65 per colliding pair and two exit 0 for separate paths.
4. Run `./bench.sh` (about 15 minutes) and `./mixed.sh` for the timing tables. [`run.py`](https://macswarm.com/lab/parallel-xcodebuild/run.py) records each batch.
5. Run `./spm-demo.sh` for the shared checkout tests. Expect mostly exit 74 for cold shared resolves and exit 0 for seeded ones.
6. Run `./xcresult-demo.sh`, then `./cleanup.sh`, which removes only what the scripts created in that directory.

The raw data behind this article is in [`timings.csv`](https://macswarm.com/lab/parallel-xcodebuild/timings.csv) and [`spm-resolves.csv`](https://macswarm.com/lab/parallel-xcodebuild/spm-resolves.csv).

## How this relates to Macswarm

Macswarm runs Claude Code and Codex sessions on Pods, macOS virtual machines with the Latest Xcode. A Pod gives each session its own git worktree, runs builds through a queue with two `xcodebuild` slots in which the agent that has waited longest goes next, and leases Simulators from a pool of three to four pre-booted devices. Those are documented product limits; this laptop experiment did not measure a Pod and does not validate them. The polling helper above is not Macswarm's scheduler, and Macswarm is not a CI service. The limits are in [Limits and queues](https://macswarm.com/docs/limits-and-queues/) and [Inside a Pod](https://macswarm.com/docs/inside-a-pod/).

## Sources

- Measurements, scripts and raw data: [Macswarm lab, parallel xcodebuild](https://macswarm.com/lab/parallel-xcodebuild/README.txt), run on 26 Oct 2026 on the machine described under How we measured. Error messages are quoted from that run's logs.
- Tool documentation in Xcode 26.6, read 26 Oct 2026: `xcodebuild -help`, `man xcodebuild`, `xcrun simctl help`, `xcrun xcresulttool help`; and `man lockf` on macOS 26.3.
- `simctl` documentation pointer: [developer.apple.com, Xcode command-line tool reference](https://developer.apple.com/documentation/xcode/xcode-command-line-tool-reference), checked 26 Oct 2026.
- Parallel testing on Simulator clones: [developer.apple.com, Xcode 10 release notes](https://developer.apple.com/documentation/xcode-release-notes/xcode-10-release-notes), and [help.apple.com, Select other run options for your tests](https://help.apple.com/xcode/mac/current/en.lproj/dev6061748b5.html), both checked 26 Oct 2026.
- Worktrees: [git-scm.com, git-worktree](https://git-scm.com/docs/git-worktree), checked 26 Oct 2026.
- Package.resolved: [docs.swift.org, Resolving and updating dependencies](https://docs.swift.org/latest/documentation/packagemanagerdocs/resolvingpackageversions/), checked 26 Oct 2026.
