Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Queue Agent Tasks Safely on One Rented Mac

One rented Mac, one agent process, many incoming tasks: the only thing you really need to prove is that one task runs at a time and no task is silently dropped or run twice. A hosted broker (SQS, Redis, Temporal) solves this at scale, but a shell agent on a single Mac does not need that. A file-based state machine plus a directory lock gives safe serial execution with nothing to operate.

If the tasks themselves are plain shell, Python, Node, curl, image/PDF tooling or any Intel-compatible binary, the Figment virtual Intel macOS plan at $29/month is the right starting point: 2 vCPU, 8 GiB RAM, 80 GiB disk, accessed through a browser console. Move up to an M-series Mac from $95/month only when a task must execute ARM-only binaries or you need Xcode 27 or visionOS tooling, which require Apple silicon (Apple porting guide).

Why a Single-Worker Queue Needs Explicit State

A polling agent that fetches "the next task" and runs it has two silent failure modes:

  1. Double execution — the agent crashes mid-task, nothing recorded the outcome, and the next poll picks up the same task.
  2. Dropped tasks — the agent starts a task, the host restarts, and nobody ever reconciles it.

Both are prevented by writing task state to disk at every transition. Each task owns a directory and moves through four states:

PENDING → RUNNING → DONE
                  ↘ FAILED

The worker only picks up PENDING. Anything found in RUNNING at startup belonged to a previous run that died; the worker demotes it to FAILED with a reason so a human or a retry policy decides what happens next.

The State Machine Implementation

A single mkdir is atomic on APFS, so it doubles as the worker lock. No external tools, no flock, no daemons beyond launchd.

#!/usr/bin/env bash
# queue-loop.sh — single-worker queue state machine
set -euo pipefail

QUEUE_DIR="${HOME}/agent-queue"
LOCK_DIR="${QUEUE_DIR}/.worker.lock"
LOG="${QUEUE_DIR}/worker.log"

mkdir -p "$QUEUE_DIR"
log() { echo "[$(date -u +%FT%TZ)] $*" | tee -a "$LOG"; }

# Acquire exclusive lock (mkdir is atomic). Exit quietly if already held.
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  log "Another worker is running. Exiting."
  exit 0
fi
trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT

# Startup reconciliation: anything still RUNNING died previously.
for task_dir in "$QUEUE_DIR"/*/; do
  [[ -f "$task_dir/state" ]] || continue
  if [[ "$(cat "$task_dir/state")" == "RUNNING" ]]; then
    log "Reclaiming interrupted task $(basename "$task_dir")"
    echo "FAILED" > "$task_dir/state"
    echo "interrupted_at_startup" > "$task_dir/failure_reason"
  fi
done

# Pick one PENDING task.
NEXT_TASK=""
for task_dir in "$QUEUE_DIR"/*/; do
  [[ -f "$task_dir/state" ]] || continue
  if [[ "$(cat "$task_dir/state")" == "PENDING" ]]; then
    NEXT_TASK="$task_dir"; break
  fi
done
[[ -z "$NEXT_TASK" ]] && { log "Queue empty."; exit 0; }

TASK_ID=$(basename "$NEXT_TASK")
echo "RUNNING" > "$NEXT_TASK/state"
date -u +%FT%TZ > "$NEXT_TASK/started_at"
log "Starting $TASK_ID"

if bash "$NEXT_TASK/run.sh" >> "$NEXT_TASK/output.log" 2>&1; then
  echo "DONE" > "$NEXT_TASK/state"
  date -u +%FT%TZ > "$NEXT_TASK/completed_at"
  log "$TASK_ID DONE"
else
  rc=$?
  echo "FAILED" > "$NEXT_TASK/state"
  echo "exit_code_${rc}" > "$NEXT_TASK/failure_reason"
  log "$TASK_ID FAILED rc=$rc"
  exit "$rc"
fi

Enqueue a task by writing a run.sh and a state file:

# enqueue-task.sh <task-id>  (pipe the script body on stdin)
TASK_ID="${1:-task-$(date +%s)}"
TASK_DIR="${HOME}/agent-queue/${TASK_ID}"
mkdir -p "$TASK_DIR"
cat > "$TASK_DIR/run.sh"
chmod +x "$TASK_DIR/run.sh"
echo "PENDING" > "$TASK_DIR/state"
date -u +%FT%TZ > "$TASK_DIR/enqueued_at"

This is an unexecuted example. Run it against a throwaway task before pointing it at real work.

Install as a launchd Agent

Each invocation does one poll and exits. StartInterval schedules the next poll; KeepAlive.SuccessfulExit = false tells launchd to restart the worker only when it exits non-zero, i.e. after a failed task. The two keys evaluate independently (launchd.plist(5), Apple Creating Launchd Jobs).

<!-- ~/Library/LaunchAgents/com.yourteam.queue-worker.plist -->
<plist version="1.0">
<dict>
  <key>Label</key><string>com.yourteam.queue-worker</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/Users/agent/agent-queue/queue-loop.sh</string>
  </array>
  <key>StartInterval</key><integer>30</integer>
  <key>KeepAlive</key>
  <dict><key>SuccessfulExit</key><false/></dict>
  <key>StandardOutPath</key><string>/Users/agent/agent-queue/worker.log</string>
  <key>StandardErrorPath</key><string>/Users/agent/agent-queue/worker-err.log</string>
</dict>
</plist>

Load with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yourteam.queue-worker.plist. Setting SuccessfulExit implies RunAtLoad, so the first poll happens immediately.

Concurrency Acceptance Test

Before trusting the queue, prove the lock works. This test launches two workers against one PENDING task and asserts it ran exactly once.

#!/usr/bin/env bash
# concurrency-test.sh
set -euo pipefail
QUEUE_DIR="${HOME}/agent-queue"
TASK_ID="concurrency-test-$(date +%s)"
TASK_DIR="${QUEUE_DIR}/${TASK_ID}"
mkdir -p "$TASK_DIR"
printf '#!/usr/bin/env bash\nsleep 5\necho "pid=$$" >> "%s/execution_log"\n' \
  "$TASK_DIR" > "$TASK_DIR/run.sh"
chmod +x "$TASK_DIR/run.sh"
echo "PENDING" > "$TASK_DIR/state"

bash "${QUEUE_DIR}/queue-loop.sh" & P1=$!
bash "${QUEUE_DIR}/queue-loop.sh" & P2=$!
wait "$P1" || true
wait "$P2" || true

count=$( [[ -f "$TASK_DIR/execution_log" ]] && wc -l < "$TASK_DIR/execution_log" || echo 0 )
state=$(cat "$TASK_DIR/state")
echo "state=$state count=$count"
if [[ "$count" -eq 1 && "$state" == "DONE" ]]; then
  echo "PASS"; rm -rf "$TASK_DIR"
else
  echo "FAIL"; exit 1
fi

Re-run this after any OS update, launchd reload or edit to queue-loop.sh. A failed test is a real failure, not a benchmark data point.

Failure and Recovery Checklist

  • concurrency-test.sh prints PASS.
  • worker.log shows the ordered transitions PENDING → RUNNING → DONE.
  • A task whose run.sh ends with exit 1 ends in state FAILED with a failure_reason.
  • After launchctl kickstart -k gui/$(id -u)/com.yourteam.queue-worker during a running task, the task is left in FAILED on the next poll, not RUNNING.
  • ~/agent-queue/.worker.lock is absent while no worker is active.

Cost and Operating Limits

FactorVirtual Intel macOS, $29/moM-series from $95/mo
Concurrent workers on this design1 (lock is per host)1 per plist; use separate hosts for real parallelism
Disk for queue directories80 GiB includedConfirmed in your quote
Task runtime requirementsIntel-compatible binaries, shell, Python, Node, curlARM-native binaries, Xcode where chip/OS allow
Trigger to upgraderun.sh needs an ARM-only binary, Xcode 27 or visionOS—

The single-worker design is deliberate: multiple workers on one host need a distributed lock, and at that point you want multiple hosts instead. The Managed Mac Team plan at $999/month includes two M-series Macs, two CI runners, backups, monitoring and one hour of monthly support — the natural next step when one queue is no longer enough. Exact hardware specs, disk sizes and physical delivery are confirmed in the quote.

Start your hosted Mac · All Mac hosting plans

Related: Mac agent large uploads · Mac Safari scheduled PDF · GitHub Actions self-hosted runner on Mac


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms