Skip to main content

Minions Worker Deployment Guide

Keep modusbrain 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).
When the worker dies, submitted jobs sit in 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:
Exit codes: 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:
Every lifecycle event (spawn, crash, backoff, health warning, max-crashes, shutdown) is also written to ${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.
The shipped unit file invokes 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

The [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

Drop Procfile 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.
Replace 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:
  1. Stop the worker before upgrading. modusbrain jobs supervisor stop (or sudo systemctl stop modusbrain-worker). Skipping this risks an in-flight job landing partial schema.
  2. Run modusbrain upgrade. Then modusbrain apply-migrations --yes if modusbrain doctor reports any migration as partial or pending.
  3. If you run shell jobs: from v0.14 onward, pass --allow-shell-jobs to the supervisor (or keep MODUSBRAIN_ALLOW_SHELL_JOBS=1 in /etc/modusbrain.env). Submitters don’t need the flag; only the worker does.
  4. Verify. modusbrain doctor should report zero pending or partial migrations plus a healthy supervisor check. modusbrain jobs stats should show no unexplained growth in dead between 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 after max_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 — see src/schema.sql and src/core/pglite-schema.ts). Five missed heartbeats before dead-letter.
  • stalledInterval: 30000 (30 s) — checks too aggressively.
Tune per-job today. 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):
systemd:
Fly / Render / Railway: delete the 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.