
Agent jobs run twice more often than you expect. A host reboots mid-task, a queue redelivers a message because the acknowledgement was lost, or a retry policy fires before the first run finishes. Each duplicate is harmless only if the job is idempotent: identical inputs produce the same outputs and no extra side effects, no matter how many times the job runs.
This page shows the exact ledger pattern we recommend for hosted Mac agents, with a worked three-delivery example you can replay on your own host.
Most idempotency-key ledgers are tiny: SQLite writes, file checks, and outbound API calls. The virtual Intel macOS plan at $29/month handles that workload comfortably with 2 vCPU, 8 GiB RAM and 80 GiB disk, and runs compatible Intel Mac toolchains including supported Xcode 26 or earlier. Access is through the browser console and the desktop terminal; outbound agents can connect to your queue or CI service from the VM.
Pick the M-series plan from $95/month when the job itself has to execute Apple silicon binaries, use Xcode 27, or run tooling that requires ARM. The exact chip, RAM and disk for an M-series plan are set in the quote. The i3 Mac at $49 and physical M-series tiers currently collect requests until supply is confirmed, so ask for availability when you request a configuration.
Most queue systems, webhooks and managed CI triggers guarantee at-least-once delivery, not exactly-once. That is a deliberate availability trade-off, and the consumer owns deduplication. A job that is not idempotent can:
An idempotency key ties each logical operation to a single identifier. If the same key arrives again, the second run reads the recorded outcome from the first run and returns it without repeating any side effect.
SQLite is a good fit: it handles concurrent reads safely, needs no server, and survives reboots. INSERT OR IGNORE silently skips a row whose PRIMARY KEY already exists (see SQLite conflict resolution), which is what makes the claim step atomic. One process wins the insert; everyone else sees the existing row.
The examples below interpolate ${key} into SQL for readability. In production, bind the key as a parameter (for example via sqlite3 -cmd ".param set" or a client library) so a key containing a quote cannot break the statement. Treat the token lookup as a reminder that any job touching a credential should pull it from a secret store, not from shell history or a plaintext env file.
#!/usr/bin/env bash
# idempotency-lib.sh — source this in any agent job script
set -euo pipefail
LEDGER_DB="${HOME}/agent-workspace/idempotency.db"
ledger_init() {
sqlite3 "$LEDGER_DB" <<'SQL'
CREATE TABLE IF NOT EXISTS ledger (
key TEXT PRIMARY KEY,
status TEXT NOT NULL, -- 'running' | 'done' | 'failed'
result TEXT,
started_at TEXT NOT NULL,
finished_at TEXT
);
SQL
}
# Claim a key atomically. Returns 0 if this call inserted the row, 1 if not.
ledger_claim() {
local key="$1" now
now=$(date -u +%FT%TZ)
sqlite3 "$LEDGER_DB" <<SQL
INSERT OR IGNORE INTO ledger (key, status, started_at)
VALUES ('${key}', 'running', '${now}');
SQL
local rows
rows=$(sqlite3 "$LEDGER_DB" \
"SELECT COUNT(*) FROM ledger WHERE key='${key}' AND status='running' AND started_at='${now}';")
[[ "$rows" -eq 1 ]]
}
ledger_finish() {
local key="$1" status="$2" result="${3:-}"
local now
now=$(date -u +%FT%TZ)
sqlite3 "$LEDGER_DB" <<SQL
UPDATE ledger
SET status='${status}', result='${result}', finished_at='${now}'
WHERE key='${key}';
SQL
}
ledger_result() {
local key="$1"
sqlite3 "$LEDGER_DB" \
"SELECT result FROM ledger WHERE key='${key}' AND status='done' LIMIT 1;"
}
The caller supplies the key. If the job generates its own key, two deliveries of the same logical operation become two different keys, and both run. A UUID carried on the task payload works, so does a deterministic hash of the inputs.
#!/usr/bin/env bash
# job-create-pr.sh — idempotent pull-request creation
set -euo pipefail
source "${HOME}/agent-workspace/idempotency-lib.sh"
ledger_init
IDEMPOTENCY_KEY="${1:?Usage: job-create-pr.sh <idempotency-key> <branch> <title>}"
BRANCH="${2:?}"
PR_TITLE="${3:?}"
log() { echo "[$(date -u +%FT%TZ)] [${IDEMPOTENCY_KEY}] $*"; }
# 1. Return the previous result if this key already completed
PREVIOUS=$(ledger_result "$IDEMPOTENCY_KEY")
if [[ -n "$PREVIOUS" ]]; then
log "Duplicate delivery. Previous result: $PREVIOUS."
echo "$PREVIOUS"; exit 0
fi
# 2. Try to claim the key — only one process wins
if ! ledger_claim "$IDEMPOTENCY_KEY"; then
log "Key already in progress. Exiting."
exit 0
fi
# 3. Do the real work; pull the token from the keychain, not env
log "Creating pull request for branch $BRANCH"
GH_TOKEN=$(security find-generic-password -a "$USER" -s "gh-token" -w)
if ! PR_URL=$(curl --silent --show-error --fail \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Content-Type: application/json" \
-X POST "https://api.github.com/repos/${GITHUB_REPO}/pulls" \
-d "{\"title\":\"${PR_TITLE}\",\"head\":\"${BRANCH}\",\"base\":\"main\"}" \
| jq -r '.html_url'); then
log "PR creation failed"
ledger_finish "$IDEMPOTENCY_KEY" "failed" "api_error"
exit 1
fi
log "PR created: $PR_URL"
ledger_finish "$IDEMPOTENCY_KEY" "done" "$PR_URL"
echo "$PR_URL"
This is an unexecuted illustration, not a benchmark. Run it against your own repository and keychain entry before relying on it.
A webhook fires three times because the first acknowledgement never came back inside the sender's timeout.
T=0s Delivery 1 arrives. key=abc-123. ledger_claim succeeds.
T=1s Job starts creating the PR on GitHub.
T=2s Delivery 2 arrives. key=abc-123. ledger_claim returns false. Exit 0.
T=4s Delivery 1 finishes. ledger_finish records status=done, result=https://github.com/…/pull/42.
T=5s Delivery 3 arrives. ledger_result returns the PR URL. Exit 0, print the URL.
One pull request, three deliveries handled. You can replay the scenario on the hosted Mac:
bash job-create-pr.sh "abc-123" "feature/x" "Add feature X" & # delivery 1
bash job-create-pr.sh "abc-123" "feature/x" "Add feature X" # delivery 2
sleep 6
bash job-create-pr.sh "abc-123" "feature/x" "Add feature X" # delivery 3
Deliveries 2 and 3 should log a duplicate and exit 0.
sqlite3 ~/agent-workspace/idempotency.db "SELECT * FROM ledger WHERE status='running' AND started_at < datetime('now', '-10 minutes');" and decide the recovery policy (requeue, mark failed, or page a human).failed row does not block a deliberate retry: use a new key for the retried logical operation, not the old one.sqlite3 ~/agent-workspace/idempotency.db "DELETE FROM ledger WHERE finished_at < datetime('now', '-30 days');"launchd, use a Label, keep the ProgramArguments absolute, and let launchd own the process lifecycle rather than forking a daemon yourself (Apple: creating launchd jobs).A typical ledger check is a two-statement SQLite round trip and finishes in a few milliseconds on the Intel VM. A million rows with short result strings sits around 100 MB, well inside the included 80 GiB once you keep the TTL sweep running (drop very old rows or archive them off the VM).
If you only need the ledger and the outbound API calls, the $29 virtual Intel macOS plan is the direct answer. Move to M-series from $95/month when the same job must build or run Apple silicon binaries or Xcode 27. The $29 price is monthly; a two-week run of a monthly plan still bills the full month, so plan the switch on month boundaries if you alternate hardware classes.
See all Mac hosting plans or request your Mac at /mac-hosting/start. If you already run CI on a self-hosted runner, the ledger drops in beside the runner on the same host.
Related: Mac Safari scheduled PDF · Mac Shortcuts command-line · GitHub Actions self-hosted runner on Mac