Quartet / Quintet

2026-09-08  ·  devops · macos · automation · programming

A Scheduled Job That Works and Never Runs

I have a job that publishes one article a day. Over four days I rebuilt its scheduling three times, and each rebuild fixed a real failure that the previous version could not have shown me.

Every version passed the test I gave it. The test was always "does it work now", never "does it work where it is going to live".

Version 1: a loop in /tmp

nohup bash /tmp/daily-loop.sh &

It ran. It published. Four scheduled items were riding on it.

Then the machine restarted and all four were gone. /tmp is not a place you put a program, and nohup does not survive a reboot. I knew both of those facts and still did this, because the thing I was testing was the publishing logic, and the publishing logic was fine.

Nothing reported the loss. The next morning there was simply no new article, and no error anywhere, because the process that would have logged the error did not exist.

Version 2: launchd with KeepAlive

Moved the script into the repository, wrote a launch agent, KeepAlive: true so it comes back if it dies.

The next morning:

scripts/daily-publish.sh: line 59: node: command not found

A job started by launchd gets almost no PATH. Mine was /usr/bin:/bin:/usr/sbin:/sbin. Node is in /opt/homebrew/bin. When I ran the same script from my own shell it worked, because my shell had the PATH.

The part that made this expensive: the same loop also runs a reconciliation step written in Python, and python3 is in /usr/bin. So that step kept working. The log filled up with successful runs every fifteen minutes.

A partly working automation is harder to notice than a dead one. The dead one has no log. This one had a log that looked healthy.

Fix is boring:

export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

I put it in every script the scheduler touches, not only the one that broke. One entry point was not the whole story: another script could invoke the same thing from a different parent.

Version 3: the loop was frozen, not running

PATH fixed, publishing worked, and then the log went quiet for three hours. The process was alive:

$ ps -o pid,etime,command -p 72127
  PID     ELAPSED COMMAND
72127    02:55:52 sleep 900

sleep 900 had been alive for two hours and fifty-five minutes.

That is a laptop. It sleeps. A sleep that is supposed to last fifteen minutes does not necessarily advance while the system is suspended, so every suspend adds an unbounded gap to a while true; do work; sleep N; done loop.

The loop was not broken. It was not stuck on a network call. It was doing exactly what I wrote, and what I wrote assumed wall-clock time keeps flowing.

Version 4: let the scheduler schedule

<key>StartInterval</key>
<integer>900</integer>
<key>RunAtLoad</key>
<true/>

The script now does one pass and exits. No loop, no sleep. StartInterval is the mechanism built for this: launchd fires the job on wake if the interval elapsed while the machine was asleep, and it will not start a second instance of the same label while one is running, so the lock I would otherwise need is gone.

I also deleted the old looping script instead of leaving it beside the new one. Two entry points for the same job means never being sure which one is running.

One more trap on the way out

I checked whether the interval had registered:

$ launchctl list com.example.daily
{
    "Label" = "com.example.daily";
    "LastExitStatus" = 0;
    ...
};

No StartInterval. I was one command away from concluding the schedule had silently failed to load and rewriting it again.

$ launchctl print gui/$(id -u)/com.example.daily
    state = not running
    runs = 4
    last exit code = 0
    run interval = 900 seconds

launchctl list does not print every key it holds. launchctl print does. The absence of a key in one tool's output is not evidence about the job.

What the four versions have in common

Each fix was correct. Each was verified. Each verification was performed in an environment the job would never actually run in.

The rule I ended up with is narrow and I have not managed to argue myself out of it yet:

When you change where something runs, run it once in the new place before believing anything you knew about it.

Not a fresh test of the logic. A test in the new environment, of the same logic, looking for the environment's own failure modes: what the PATH is, what happens across a reboot, what happens across a suspend, and what the tool you use to inspect it declines to tell you.

The fourth version has now survived several suspends and one reboot. That is not proof, but it is the first version whose evidence came from the place it lives.


I publish the configuration for splitting Claude Code into separate personas — Architect, Coder, Reviewer, Conflict Resolver — under MIT. Copy it, run ./setup.sh, and it works. It does not depend on your tech stack.

https://github.com/quintetkit/quartet

I built one real tool using nothing but this workflow. Every Issue, PR, review and merge is still there. The parts that went wrong were not deleted.

https://github.com/quintetkit/mdlinkcheck

The version that adds a UI Designer persona, review criteria, a per-Issue parallel execution script and a 10-chapter guide is on the product page.

The full kit — five personas, the scripts and the complete guide — is available here.

https://quintetkit.gumroad.com/l/quintet

The workflow itself is available

Quartet, the four-persona version, is published free under MIT. Quintet adds a UI Designer persona, review criteria, a per-Issue parallel execution script, and a 10-chapter guide.

See the free version Product page