Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Recover a Crashed App in Remote Mac Automation

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.

Pick the Figment plan that fits this job

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.

Inputs and permissions to collect first

InputWhere to find it
Full path to the app binarywhich myapp, or inside the .app bundle at Contents/MacOS/
Working directoryYour project root on the Mac
Checkpoint file pathApp config, or an environment variable you control
Log directoryAny path writable by the agent user
Relaunch ceilingInteger 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.

Implement the bounded relaunch workflow

The design pairs launchd's built-in restart machinery with a thin wrapper that tracks retries and arms the checkpoint resume flag.

launchd plist — restart only on failure, with backoff

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

Wrapper script — counts relaunches, passes the checkpoint

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

Verify restart, ceiling and checkpoint

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

Worked cost worksheet for one agent

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.

  • Normal day, no crashes: 1 launch × 50 requests × $0.004 = $0.20/day in API spend.
  • Pathological day, ceiling hit: 5 launches × 50 requests × $0.004 = $1.00/day, then the job stops until you clear .relaunch_count.
  • Without a ceiling, the same fault at a 30-second throttle floor could spawn 2,880 launches in 24 hours — $576/day before any alert fires.

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.

Start or migrate the workload

Request your configuration at Figment Mac hosting, then follow the Mac hosting quick start. Once the Mac is ready:

  1. Write /usr/local/bin/agent-wrapper with the script above; chmod 755.
  2. Place the plist in ~/Library/LaunchAgents/, owned by the agent user, not group- or world-writable.
  3. Run launchctl load ~/Library/LaunchAgents/com.yourorg.automation-agent.plist.
  4. Execute the three checks before treating the workflow as production.

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


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms