Operate the automation
Prevent overlapping runs
Use a lock or launchd lifecycle behavior so two triggers cannot modify the same files simultaneously.
Schedules, Finder actions, and manual retries can overlap. You click the Quick Action while the interval job is mid-run. A slow run is still working when the next interval fires.
Two instances may pick the same destination before either finishes writing. The result is duplicated or corrupted output that neither run can explain on its own.
What launchd gives you
launchd gives you one guarantee for free. It does not start a second instance of a label while the first is still running.
But that only covers launchd’s own copies. The same script run by hand, or through a Quick Action, is a separate process launchd knows nothing about. For that you need a lock: a marker that says “someone is already working here”.
A lock with mkdir
macOS does not ship a flock command. The portable pattern is mkdir, because mkdir is atomic. It either creates the directory or fails. There is nothing in between, so two processes can’t both succeed.
lock="$HOME/.local/state/screenshot-sorter.lock"
if ! mkdir "$lock" 2>/dev/null; then
echo "another run holds the lock, exiting" >&2
exit 0
fi
echo $$ > "$lock/pid"
trap 'rm -rf "$lock"' EXIT
Three steps. Create the lock atomically. Store the owning process id inside it. Remove it on exit. The trap releases the lock even when the script fails partway through.
Make sure ~/.local/state exists first, or mkdir fails for the wrong reason and every run thinks another run holds the lock.
Verify the collision
Simulate two runs at once:
./sort-screenshots & ./sort-screenshots
# another run holds the lock, exiting
One run does the work. The other sees the lock and leaves. That’s the whole point.
Stale locks
A hard kill can leave the directory behind. Then every future run refuses to start, forever.
Handle this as a separate, verified path. Read $lock/pid. Check whether that process is still alive with kill -0 "$pid". Only then conclude the lock is stale and remove it.
Don’t delete locks blindly at startup. That brings back the exact race the lock exists to prevent.
Pick a policy
Decide what new work should do when it finds the lock: wait, exit, or replace the older run.
The example exits, which suits an interval job. The next scheduled run picks up whatever is left. For a Quick Action you might prefer waiting a few seconds, because the user is standing there.
A lock without a documented policy only changes the failure. Write the policy in the runbook, one line.
Try this: add the lock to your sort script and run the collision test. Then kill -9 a run mid-way, confirm the lock is left behind, and write the stale-lock check.
Lesson completed