Minions Worker Deployment Guide
Keepmodusbrain jobs work running across crashes, reboots, and Postgres
connection blips. Written for agents to execute line-by-line.
The problem
The persistent worker can die silently from:- Database connection drops (Supabase/Postgres maintenance or network blips).
- Lock-renewal failures → the stall detector eventually dead-letters jobs.
- Bun process crashes with no automatic restart.
- Internal event-loop death (PID alive, worker loop stopped).
waiting forever. The
canonical answer is modusbrain jobs supervisor — a first-class CLI that
spawns modusbrain jobs work as a child and auto-restarts it on crash.
Worker supervision
The canonical pattern
modusbrain jobs supervisor is an auto-restarting wrapper around
modusbrain jobs work. It writes a PID file, restarts the worker on crash
with exponential backoff (1s → 60s cap), emits lifecycle events to an
audit file, and drains gracefully on SIGTERM (35s worker-drain window
before SIGKILL). Exit codes are documented so agents can branch on them.
Typical commands:
An agent seeing exit=2 can safely treat it as “one is already running”;
exit=1 should page a human.
Lowering scheduling priority (--nice)
When the worker pool runs at full concurrency on a machine you also use
interactively, it can drive the load average high enough to starve your
shell. Cutting --concurrency throws away throughput. Reach for --nice
instead — it lowers the job tree’s CPU scheduling priority without touching
width, so the work runs full-speed when the box is idle and yields when it
isn’t:
--nice takes a POSIX value from -20 (highest priority) to 19
(nicest/lowest); positive values need no privilege, negative values need
root. MODUSBRAIN_NICE is the env equivalent (the flag wins). Confirm the
effective value with modusbrain jobs stats, modusbrain jobs supervisor status --json, or the supervisor_niceness check in modusbrain doctor — the doctor
check warns if what you asked for isn’t what’s actually running (e.g. a
negative value denied without privilege, or an OS RLIMIT_NICE clamp). This
is distinct from the concurrency / inflight cap and composes with it.
Which supervisor when?
The supervisor solves in-process crash recovery. Platform-level supervision (systemd, Fly, Render) handles host-level failures. You usually want both.Variables used in this guide
Substitute these once before copy-pasting any snippet.Preconditions
Run these before any deployment step.Agent usage (OpenClaw / Hermes / Cursor / Codex)
Three-command pattern an agent can drive without shell archaeology:${MODUSBRAIN_AUDIT_DIR:-~/.modusbrain/audit}/supervisor-YYYY-Www.jsonl
for historical inspection. modusbrain doctor reads that file and surfaces
a supervisor check in its health report.
Deployment: systemd
For long-running Linux VMs with shell access.modusbrain jobs supervisor (not modusbrain jobs work
directly) so you get two-layer supervision: systemd restarts the supervisor
on host reboot, supervisor restarts the worker on in-process crash.
Restart=always + RestartSec=10s handle the supervisor-level recovery.
The unit runs as unprivileged modusbrain with PrivateTmp, ProtectSystem=strict,
and ReadWritePaths=$MODUSBRAIN_WORKSPACE,$HOME/.modusbrain (for the PID file and
audit log). LimitNOFILE=65535 covers Bun + Postgres pool + concurrent
LLM subagent calls without hitting the default 1024 cap.
Deployment: Fly.io
[processes] block runs modusbrain jobs supervisor as PID 1. Fly
restarts the container on host failure; the supervisor restarts the
worker on in-process crash.
Deployment: Render / Railway / Heroku
DropProcfile at the repo
root. The shipped Procfile calls modusbrain jobs supervisor. Set
DATABASE_URL + optional MODUSBRAIN_ALLOW_SHELL_JOBS=1 via the platform’s
env UI or CLI.
Deployment: inline --follow (no persistent worker)
For short deterministic scripts on a fixed schedule where you don’t need
a persistent worker between runs. Each cron run brings its own temporary
worker. --follow starts one on the queue and blocks until the
just-submitted job reaches a terminal state (completed / failed /
dead / cancelled). 2-3 s startup overhead per job; negligible vs job
duration for scheduled work.
modusbrain embed --stale with whichever modusbrain subcommand you’re
scheduling (sync, extract, orphans, doctor, check-backlinks,
lint, autopilot). For strict single-job semantics on shared queues,
use a dedicated queue name like nightly-enrich above.
Upgrading from an older deployment
From minion-watchdog.sh (pre-v0.20)
Earlier versions of this guide shipped a 68-line bash watchdog
(minion-watchdog.sh). It’s been replaced by modusbrain jobs supervisor
which handles everything the script did, plus atomic PID locking,
structured audit events, queue-scoped health checks, and graceful
drain on SIGTERM.
Migration:
Schema / migration hygiene
Regardless of which deployment path you’re upgrading from:- Stop the worker before upgrading.
modusbrain jobs supervisor stop(orsudo systemctl stop modusbrain-worker). Skipping this risks an in-flight job landing partial schema. - Run
modusbrain upgrade. Thenmodusbrain apply-migrations --yesifmodusbrain doctorreports any migration aspartialorpending. - If you run shell jobs: from v0.14 onward, pass
--allow-shell-jobsto the supervisor (or keepMODUSBRAIN_ALLOW_SHELL_JOBS=1in/etc/modusbrain.env). Submitters don’t need the flag; only the worker does. - Verify.
modusbrain doctorshould report zeropendingorpartialmigrations plus a healthysupervisorcheck.modusbrain jobs statsshould show no unexplained growth indeadbetween pre- and post-upgrade.
Known issues
Supabase connection drops
The worker uses a single Postgres connection. If Supabase drops it (maintenance, connection limits, network blip), lock renewal fails silently. The stall detector then dead-letters the job aftermax_stalled misses.
Current defaults that make this worse:
lockDuration: 30000(30 s) — too short for long jobs during connection blips.max_stalled: 5(schema column default — seesrc/schema.sqlandsrc/core/pglite-schema.ts). Five missed heartbeats before dead-letter.stalledInterval: 30000(30 s) — checks too aggressively.
modusbrain jobs submit accepts --max-stalled N,
--backoff-type fixed|exponential, --backoff-delay <ms>,
--backoff-jitter 0..1, and --timeout-ms N as first-class flags
(since v0.13.1). These write onto the job row at submit time — which is
what handleStalled() reads — so per-job tuning is the real knob today.
DO NOT pass maxStalledCount to MinionWorker
It’s a no-op. The stall detector reads the row’s max_stalled column
(set at submit time), not the worker opt in src/core/minions/worker.ts:74.
Use modusbrain jobs submit --max-stalled N per-job instead.
Zombie shell children
When the Bun worker crashes hard, child processes from shell jobs can become zombies. The supervisor’s SIGTERM → 35s drain → SIGKILL window covers the shell handler’s 5 s child-kill grace (KILL_GRACE_MS). For
long-running shell jobs, prefer timeouts via --timeout-ms on submit
over relying on hard kills.
Smoke test
Uninstall
modusbrain jobs supervisor (foreground or --detach):
worker process from fly.toml
/ Procfile and redeploy. Secrets set via fly secrets persist until
fly secrets unset.
Inline --follow: remove the cron entry. Nothing else to clean up
— temporary workers exit with their jobs.