
When an automation app crashes on a remote Mac, two things break at once: the job stops, and whatever state the app held in memory disappears. A naive restart-on-crash loop makes it worse — it spins through the same fault and exhausts your API quota before anyone notices. The fix is a bounded relaunch workflow: restart only on failed exits, cap the retries, and resume from a checkpoint instead of starting over.
For a single headless agent that drives a web API, orchestrates scripts, or runs a GUI tool with an Intel-compatible build, start on the Figment virtual Intel macOS VM at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk). It runs real macOS, you reach it through the browser console terminal, and compatible Intel Mac toolchains — including supported Xcode 26-or-earlier releases — work inside it. There is no inbound SSH, no GPU and no audio, so design any crash-recovery script around the console terminal and outbound connections to your own services.
Choose the M-series tier from $95/month when your app ships ARM-only binaries, needs Apple-silicon-only frameworks, or requires Xcode 27. The $95 entry is a lower starting price, not an M4 configuration — the exact chip, RAM and disk are set in the quote, and the physical plans currently collect requests until supply is confirmed. If you want the restart policy and backups operated for you, the Managed Build Mac at $249/month includes one M-series Mac, CI setup, backups, monitoring and 30 minutes of monthly support.
| Input | Where to find it |
|---|---|
| Full path to the app binary | which myapp, or inside the .app bundle at Contents/MacOS/ |
| Working directory | Your project root on the Mac |
| Checkpoint file path | App config, or an environment variable you control |
| Log directory | Any path writable by the agent user |
| Relaunch ceiling | Integer matching your retry budget (e.g. 5) |
LaunchAgent or LaunchDaemon? If the app needs a user desktop session — menus, windows, or user-session Keychain access — install it as a LaunchAgent under ~/Library/LaunchAgents/. If it is fully headless and must start before any login, use a LaunchDaemon under /Library/LaunchDaemons/. Most remote automation workflows belong in a LaunchAgent. The plist must be owned by the invoking user (or root for a daemon) and must not be group- or world-writable; Apple's Creating Launch Daemons and Agents is the authoritative reference.
The design pairs launchd's built-in restart machinery with a thin wrapper that tracks retries and arms the checkpoint resume flag.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.yourorg.automation-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/agent-wrapper</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/agent/workdir</string>
<!-- Restart whenever the wrapper exits non-zero. The wrapper uses
exit 0 to stop the cycle (clean run OR retry ceiling hit). -->
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<!-- 30 s floor between restarts; launchd's default is 10 s. -->
<key>ThrottleInterval</key>
<integer>30</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/agent/logs/agent.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/agent/logs/agent.err.log</string>
</dict>
</plist>
KeepAlive with SuccessfulExit: false tells launchd to restart the job whenever the wrapper exits with a non-zero status — including the 128+N exit code bash returns when its foreground child is killed by SIGSEGV, SIGILL or SIGABRT. An explicit exit 0 from the wrapper ends the cycle. ThrottleInterval enforces a 30-second floor between spawns; the documented default in the launchd.plist(5) reference is 10 seconds, which is still tight for a stubborn fault.
KeepAlive.Crashed: true is deliberately not used here. When the wrapper runs the app as a child and the child dies by signal, bash itself exits normally with code 128+N, so launchd sees a non-zero exit, not a signal termination of its tracked process. SuccessfulExit: false catches both child-signal and regular-error paths.
#!/usr/bin/env bash
# /usr/local/bin/agent-wrapper
# Enforces a relaunch ceiling; passes checkpoint path to the app.
set -uo pipefail
MAX_RELAUNCHES=5
COUNT_FILE="/Users/agent/workdir/.relaunch_count"
CHECKPOINT="/Users/agent/workdir/checkpoint.json"
APP="/Applications/AutomationApp.app/Contents/MacOS/AutomationApp"
count=0
[[ -f "$COUNT_FILE" ]] && count=$(< "$COUNT_FILE")
if (( count >= MAX_RELAUNCHES )); then
echo "$(date -u +%FT%TZ) FATAL: relaunch limit $MAX_RELAUNCHES reached. Stopping." >&2
exit 0 # clean exit prevents launchd from restarting again
fi
printf '%d\n' $(( count + 1 )) > "$COUNT_FILE"
echo "$(date -u +%FT%TZ) Launch attempt $(( count + 1 ))/$MAX_RELAUNCHES"
"$APP" --resume-from "$CHECKPOINT"
EXIT_CODE=$?
if [[ $EXIT_CODE -eq 0 ]]; then
rm -f "$COUNT_FILE" # reset so the next scheduled run starts clean
exit 0
fi
exit "$EXIT_CODE"
The counter file survives restarts because launchd restarts the wrapper, not the filesystem. On a fully successful run the wrapper clears the counter so the next scheduled execution starts at zero. If your app does not accept --resume-from, substitute whatever it uses to skip completed work — a marker file, a database row, or a --start-at-step N argument.
After launchctl load ~/Library/LaunchAgents/com.yourorg.automation-agent.plist, run these three checks before trusting the workflow.
Check 1 — Restart fires after a non-zero wrapper exit.
pid=$(launchctl list | awk '/automation-agent/ {print $1}')
[[ -z "$pid" || "$pid" == "-" ]] && { echo "FAIL: wrapper not running"; exit 1; }
kill -TERM "$pid"
sleep 35
new_pid=$(launchctl list | awk '/automation-agent/ {print $1}')
[[ -n "$new_pid" && "$new_pid" != "-" && "$new_pid" != "$pid" ]] \
&& echo "PASS: new PID $new_pid" || echo "FAIL: no restart"
The first column of launchctl list is the PID (or - when not running) and the second is the last exit status. Match against your installed launchctl help output, since fields have shifted between macOS versions.
Check 2 — Ceiling stops the loop. Set MAX_RELAUNCHES=2, replace $APP with /bin/false (always exits non-zero), reload the plist, and watch the logs. The third invocation should print the FATAL line and exit 0; launchd should then leave the job idle. Confirm with launchctl list | grep automation-agent — the job is still registered but shows no running PID.
Check 3 — Checkpoint actually resumes. Hand-craft a checkpoint.json with two tasks complete and two pending. Start the app with --resume-from and confirm the log shows it skipping the completed items and running only the two pending ones. This exercises your app's idempotency, not launchd.
Tail logs in the browser console terminal:
tail -f /Users/agent/logs/agent.err.log
The retry ceiling and the plan both bound your worst-case bill. Suppose the agent calls an API that costs $0.004 per request and each launch issues up to 50 requests before the checkpoint advances.
.relaunch_count.Add the hosting plan on top: $29 for the virtual Intel macOS VM or from $95 for the M-series tier. That is the material number to compare against your "runaway crash loop" exposure, not the headline monthly rate.
Request your configuration at Figment Mac hosting, then follow the Mac hosting quick start. Once the Mac is ready:
/usr/local/bin/agent-wrapper with the script above; chmod 755.~/Library/LaunchAgents/, owned by the agent user, not group- or world-writable.launchctl load ~/Library/LaunchAgents/com.yourorg.automation-agent.plist.To migrate from an existing Mac, copy the working directory (including checkpoint.json and .relaunch_count if mid-run) and the plist, then update absolute paths to the new host's layout before loading.
Related guides