
A hosted Mac automation job that exits silently during a network blip leaves you with two problems: get the job running again, and figure out whether the previous run committed side effects you now need to reconcile. This guide solves both with launchd supervision, an idempotent wrapper, and a decision tree for reconciliation.
Buying recommendation. For a script-only agent (Python, Node.js, curl, shell) that calls external APIs, start on the Figment virtual Intel macOS environment at $29/month. If the job must execute ARM-native binaries or Apple-silicon-only apps, request an M-series Mac starting at $95/month; the exact chip, RAM and disk are set in the quote. Physical i3 and M-series plans are provisioned on request, so confirm availability before you cut over. Start a configuration.
Pick the plan that matches what your job actually executes. Current Figment monthly rates:
| Requirement | Plan | Monthly |
|---|---|---|
| CLI tools, Python, Node.js, curl — no ARM execution | Virtual Intel macOS | $29 |
| Heavier local I/O or concurrency needs | Physical i3 Mac | $49 |
| ARM-native binaries, Apple silicon apps | M-series Mac | from $95 |
| Managed CI setup, backups, monitoring, 30 min monthly support on one M-series | Managed Build Mac | $249 |
If your job is a standard script — polling an API, transforming JSON, pushing artifacts — the $29 virtual Intel macOS runs it without issue. It is a full Intel macOS environment, not an SSH hop, and it can host supported Intel toolchains (Xcode 26 or earlier). ARM execution, GPU acceleration, audio and Xcode 27 require Apple silicon, so route those jobs to an M-series. Physical plans collect requests until supply is confirmed; ask on the configuration form.
Access to the virtual Mac is through the browser console. Outbound connections from the VM — CI agents, API calls, webhooks — work normally. Inbound public SSH to the VM is not currently implemented, so run commands in the desktop terminal or through an outbound CI agent that reaches back to your orchestrator.
Before writing a single recovery handler, define four things:
shortcuts run call from a remote orchestrator.The macOS Shortcuts CLI lets any remote orchestrator fire a named shortcut. Per Apple's Run shortcuts from the command line, shortcuts run "ProcessBatch" --input-path /data/batch.json returns 0 on success and 1 on failure, which a wrapper script can capture before writing a state flag.
The pattern: a shell wrapper tracks job state in a file; launchd supervises the wrapper and restarts it when the network returns.
#!/usr/bin/env bash
# /usr/local/bin/run-agent-job.sh
# Example — adapt the shortcut name, API URL and paths to your project.
set -euo pipefail
STATE_DIR="$HOME/agent"
STATE_FILE="$STATE_DIR/job.state"
LOCK_DIR="$STATE_DIR/job.lock.d"
mkdir -p "$STATE_DIR"
# Prevent concurrent reruns (mkdir is atomic)
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
echo "Job already running" >&2; exit 1
fi
trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
# Reconcile any prior partial run
if [[ -f "$STATE_FILE" ]]; then
prior=$(cat "$STATE_FILE")
if [[ "$prior" != "COMPLETE" ]]; then
echo "Prior run ended at: $prior — running reconciliation" >&2
/usr/local/bin/reconcile-job.sh "$prior" || exit 1
fi
fi
echo "STARTED_$(date -u +%Y%m%dT%H%M%SZ)" > "$STATE_FILE"
# Confirm the target API is reachable before proceeding
curl -sf --max-time 10 https://api.example.com/health \
|| { echo "API not reachable yet" >&2; exit 1; }
# Main job
shortcuts run "ProcessBatch" --input-path /data/batch.json
echo "COMPLETE" > "$STATE_FILE"
Save this plist as ~/Library/LaunchAgents/com.yourteam.agentjob.plist and load it with launchctl load ~/Library/LaunchAgents/com.yourteam.agentjob.plist:
<?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.yourteam.agentjob</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/run-agent-job.sh</string>
</array>
<key>KeepAlive</key>
<dict>
<key>NetworkState</key><true/>
<key>SuccessfulExit</key><false/>
</dict>
<key>ThrottleInterval</key><integer>60</integer>
<key>StandardOutPath</key><string>/tmp/agentjob.log</string>
<key>StandardErrorPath</key><string>/tmp/agentjob.err</string>
</dict>
</plist>
Per the launchd.plist reference on launchd.info, KeepAlive → NetworkState: true starts the job while any network interface is available, and SuccessfulExit: false keeps relaunching until the wrapper exits 0. ThrottleInterval: 60 enforces a 60-second pause between invocations, preventing a tight crash loop. For rate-limited downstream APIs, raise it to 300 seconds.
launchd considers the network available as soon as any interface holds an IP address. That does not guarantee internet reachability or that your specific API is healthy — the explicit curl probe inside the wrapper closes that gap.
After an unexpected job exit, walk this tree to pick the right recovery action:
Job exited with non-zero status
│
├── STATE_FILE = STARTED_<timestamp> ← partial run
│ ├── Are all side effects idempotent for this run ID?
│ │ ├── YES → let launchd restart; wrapper retries safely
│ │ └── NO → run reconcile-job.sh <timestamp>
│ │ query the API for records already committed
│ │ de-duplicate, then allow restart
│ └── Lock dir stale (process gone, lock dir present)?
│ └── rmdir "$LOCK_DIR"; run reconciliation; restart
│
├── STATE_FILE = COMPLETE ← clean exit
│ └── Check upstream trigger; no reconciliation needed
│
└── STATE_FILE missing ← job never started
└── Restart is clean; no reconciliation needed
Side-effect reconciliation checklist:
After the network returns, confirm the job recovered:
tail -50 /tmp/agentjob.log — look for a gap of at least ThrottleInterval seconds between the failed entry and the restart. A shorter gap means a non-network bug.launchctl list | grep agentjob — a numeric PID column means the job is active; the exit-status column holds the last exit code.cat ~/agent/job.state — COMPLETE means the last full run succeeded. A STARTED_ timestamp that persists across two restarts points to a deterministic failure, not a transient drop.curl -sf https://api.example.com/health && echo ok — distinguishes a machine-level network issue from an upstream API outage.$29 is a monthly flat rate on the virtual Intel macOS. A two-week run still costs the full month; there is no published daily or pro-rated term. If your automation runs continuously and you want someone watching it, the $249 Managed Build Mac is one M-series Mac with CI setup, backups, monitoring, and 30 minutes of monthly support included — useful when you want stalled jobs surfaced before you discover them hours later. For a two-runner team setup with a one-hour monthly support allowance, the Managed Mac Team plan is $999.
Factor downstream rate limits into ThrottleInterval. A quota of 60 requests per minute should not be retried every 10 seconds during recovery — 60 to 120 seconds keeps you within the window while still restoring quickly.
Request a Figment Mac on the configuration form, copy the plist and wrapper into place, inject credentials from your CI secret manager, and load the agent with launchctl load. Confirm hardware availability in the quote before cutting over.
Get started with a hosted Mac →
Related guides: