Every 30 minutes a scheduled task wakes up, classifies what is open, writes a proposal to a file and exits. That is the entire always-on part of the autonomous PM loop we were building, the kind of thing that looks at what is open and proposes the next move. Getting there meant answering a question we had been avoiding: where does the thing that keeps waking up actually run?

A daemon is what everyone reaches for, and it is easy to skip straight to one. We left the fork open in the design doc on purpose, because we did not trust our first instinct, and ran it past codex before committing.

The two shapes

The first answer is a resident daemon. A long-lived process sits in memory, holds the orchestration state and ticks on its own timer. It also signs you up for a pile of new responsibilities. It has to survive reboots. It needs a supervisor to restart it when it dies. Its in-memory state vanishes if it crashes mid-cycle. On Windows it means standing up another service to babysit.

The second is a periodic tick. Something external wakes a short-lived process every N minutes, it does its job, writes its state to disk and exits. The orchestration state does not live in a process at all. It lives in files, and the “daemon” is just whatever fires the tick.

Restart-safety is free in the second model and expensive in the first. If your state is on disk and your worker is stateless and short-lived, a reboot is a non-event. The next tick picks up exactly where the last one left off, because there is nothing in memory to lose.

What codex called

Codex recommended the second shape, with one twist that made it click.

Use a scheduled-task tick (a Windows schtask, the cron equivalent) for the orchestration state. Do not have that tick spawn agents directly. Dispatch approved work through the endpoint the server already has: POST /api/bridge/spawn, backed by bridgeRegistry.spawnSession({cwd, seed, model}).

The twist is the load-bearing part. The server already owns a gnarly piece of platform plumbing: spawning claude.exe under the right Windows context. It runs as LocalSystem (the NSSM default), where USERPROFILE points at the wrong place. Every claude --resume then fails with “No conversation found” until you override the home directory with a CLAUDE_USER_HOME env var. That code was hard-won and it lives in the server process. If the tick spawned agents itself, it would have to re-implement the resume dance from a non-interactive scheduled-task context, which is exactly where Windows logon types, PATH resolution and drive mapping all bite. Routing dispatch back through the server’s spawn endpoint keeps the workaround where it already works. The tick never has to know about it.

The responsibilities split like this:

  • The tick resolves scope, classifies what is open and writes a proposal to .runs/pm/. It does not dispatch. It is a classifier that leaves a note.
  • The server, when a human approves a proposal, runs the gate-check and calls spawnSession in-process, right where the resume workaround lives.

That makes the loop restart-safe by construction. The tick is stateless and short-lived, so it cannot leak a half-finished cycle across a reboot. The state is files in .runs/pm/ plus the ticket’s plan checklist. Dispatch uses the same in-process endpoint the server already owns, so there is no second code path to keep in sync. How the human signals approval (a file flag in .runs/pm/, a button in the server’s own UI, or a CLI command) we left open in the ADR. That control surface gets decided before any auto-dispatch-on-approval is wired.

The shape introduces one risk, and codex flagged it: tick overlap. If a tick runs long and the next one fires before it finishes, two workers step on the same state. The fix is a lease or lock in .runs/pm/, the standard guard for any cron-style job that must not run concurrently with itself. It is worth building before you trust the loop, not after.

What we did not have to build

A nightly refresh job already runs as a Windows scheduled task. We already had the muscle memory and the tooling for “a schtask fires a short Python/Node job on a timer.” A new task every 30 minutes was one more entry in a pattern that was already load-bearing and already monitored.

The ledger came out like this. New always-on infra: none. New service to supervise: none. New restart-recovery logic: none, because the state is on disk and the worker is stateless. New elevation handling: none, because the server still owns it. What we added was a scheduled-task entry and a classifier that writes a file.

The daemon path would have cost a new long-lived process, a service wrapper to keep it alive across reboots, in-memory state with its own persistence story, and a re-implementation of the LocalSystem resume workaround in the context where it is hardest to get right. The tick erased all four.

The citation that was off

This is where we almost got burned. Codex gave a confident answer and a line citation for where the /api/bridge/spawn seam lived. The file is 1218 lines long, and the line the citation pointed at was not the endpoint.

The reasoning was right and the citation was wrong, and the wrong citation is the part that looks most authoritative. Had we taken “it’s at line X” on faith, and had the seam not existed in the shape we assumed, the whole restart-safe story would have been built on air.

So we opened the file. We confirmed that app.post("/api/bridge/spawn", ...) existed and that bridgeRegistry.spawnSession took the {cwd, seed, model} payload we planned to dispatch through. Once that was verified, we captured the whole call as a short internal design-decision note, so the next person does not have to re-derive why the loop lives in a schtask instead of a daemon.

The decision survives because the seam is real.

What I would check before building a daemon

Before you build an always-on daemon, look at whether an existing periodic-tick mechanism, plus an existing in-process dispatch endpoint, already gives you restart-safety for free. If your orchestration state can live in files and your worker can be short-lived and stateless, a tick avoids a new service to supervise and in-memory state to lose on reboot, and the hard platform workarounds stay where they already work. And when an LLM hands you a recommendation with a line citation, open the file, because the reasoning can be sound while the citation is wrong.