Parallel xcodebuild for agents: DerivedData and Simulators

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.

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:

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:

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:

Why does parallel xcodebuild report “database is locked”?
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 gives each agent its own working directory and branch without a second clone. These commands target a macOS scheme:

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

Can concurrent builds share Swift package checkouts?
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:

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:

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 explains that the file pins versions only for the top-level project. Per agent, on a macOS scheme:

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:

How does build time scale with concurrent 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.

What trade-offs does a build-slot limit introduce?
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 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:

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:

. ./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 names as the documentation for simctl, and man xcodebuild. The lease functions in 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 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); -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:

. ./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:

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:

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, on a macOS test run with one deliberate failure:

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
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 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 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 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 and 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 and Inside a Pod.

Sources

Reviewed . Facts on this page are rechecked when the product changes.