The launch script now starts merge_drive by default (T5-7: it is the controller's singleton merge stage — APPROVED -> MERGING -> MERGED). Previously the script killed merge_drive as a "legacy" process but never started it, so controller workflows dead-ended at APPROVED. merge_drive moves into the CONTROLLER process group; --no-merge skips it. Also adds the worker thread-pool concurrency knob (default 2 for the trial harness). Renames the misleadingly-named env var CONTROLLER_MAX_CONCURRENT_WORKERS_PER_MACHINE -> CONTROLLER_MAX_CONCURRENT_WORKER_THREADS_PER_MACHINE: it sizes a ThreadPoolExecutor inside one worker process, it does not spawn worker processes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
15 KiB
Controller Ops Runbook
Operational guide for the cleveragents controller (master + workers).
Pair with the systemd units in systemd/ and the env-file examples
in the same directory.
Architecture in 90 seconds
- Master singleton per (owner, repo): runs discovery, the state machine tick, the reaper, the pickup-guard, periodic reconciliation, and Forgejo writes. Enforced by one systemd unit per repo.
- Workers (N per machine, M machines): dequeue pending
workflow_attempts, spawn a per-attempt MCP subprocess + OpenCode session, write the result. Multi-machine safe via the v9 PR-level lock with TTL + heartbeat. - Postgres is the shared substrate. SQLite is supported only for single-host dev — multi-machine deployments must use Postgres.
- OpenCode is the LLM driver. Each per-attempt subprocess spawns a session against the OpenCode HTTP API.
Prerequisites
- Linux host with systemd 245+ (uses Type=simple template units).
- Python 3.13 + uv installed; project checked out at
/opt/cleveragents-core(or symlinked there) and a virtualenv at/opt/cleveragents-core/.venv. - Postgres 14+ reachable from every controller host. The DB user
needs CREATE on the controller database (the app issues
CREATE TABLE IF NOT EXISTSon first run). - OpenCode server reachable from every worker host.
- A dedicated unix user
cleveragents:cleveragents. Owns/opt/cleveragents-core,/var/lib/cleveragents, and/var/log/cleveragents(the latter two are reserved for runtime state and logs). - Forgejo PAT for a bot account with read access to PRs/issues
- write access to labels, comments, reviews, and merges.
First-time setup
-
Create the OS bits:
sudo useradd --system --create-home --shell /usr/sbin/nologin cleveragents sudo install -d -o cleveragents -g cleveragents \ /var/lib/cleveragents /var/log/cleveragents sudo install -d -o root -g root -m 0755 /etc/cleveragents -
Drop in the systemd units + env files:
sudo cp tools/controller/deploy/systemd/cleveragents-controller-master.service \ /etc/systemd/system/ sudo cp tools/controller/deploy/systemd/cleveragents-controller-worker@.service \ /etc/systemd/system/ sudo install -m 0640 -o root -g cleveragents \ tools/controller/deploy/systemd/master.env.example \ /etc/cleveragents/master.env sudo install -m 0640 -o root -g cleveragents \ tools/controller/deploy/systemd/worker.env.example \ /etc/cleveragents/worker.env sudo systemctl daemon-reload -
Edit the env files — set OWNER/REPO/DB URL/Forgejo PAT/opt-in label. The example files document every variable. Mode 0640 keeps the Forgejo PAT off other users.
-
Create the opt-in label on Forgejo: Visit
https://<forgejo>/<owner>/<repo>/labelsand add a label matchingCONTROLLER_OPT_IN_LABEL(defaultcontroller-managed). This is the migration mechanism — only labeled PRs/issues are discovered. -
Initialise the database (one-shot, master does it):
sudo systemctl start cleveragents-controller-master sudo journalctl -u cleveragents-controller-master -n 50 # Expect: "controller backfill: first-time for <owner>/<repo>" -
Start the worker pool (sized per your throughput needs):
sudo systemctl enable --now cleveragents-controller-worker@implementer-1 sudo systemctl enable --now cleveragents-controller-worker@reviewer-1 sudo systemctl enable --now cleveragents-controller-worker@estimator-1For role-specialised pools, drop a per-instance override file at
/etc/cleveragents/worker.implementer-1.envsettingCLEVERAGENTS_WORKER_ROLES=implementer. -
Verify:
sudo systemctl status cleveragents-controller-master sudo systemctl status 'cleveragents-controller-worker@*' psql "$CLEVERAGENTS_DB_URL" -c \ "SELECT current_state, COUNT(*) FROM workflows GROUP BY current_state;"
Day-to-day operations
Where the logs are
- Master:
journalctl -u cleveragents-controller-master -f - One worker:
journalctl -u cleveragents-controller-worker@implementer-1 -f - All workers:
journalctl -u 'cleveragents-controller-worker@*' -f
What "healthy" looks like
- Master logs every iteration at INFO if anything transitioned. A quiet master (no transitions for minutes) means there's no work ready — that's normal between PR pushes.
- Workers log per dequeue + per finalize. A worker that polls every 5s + finds nothing logs at DEBUG; INFO at startup + every successful attempt.
- Useful query to spot stuck workflows:
SELECT workflow_id, current_state, last_transition_at, current_tier FROM workflows WHERE current_state NOT IN ('MERGED', 'ABANDONED', 'STUCK', 'CREATED_PR') AND last_transition_at < NOW() - INTERVAL '1 hour' ORDER BY last_transition_at;
Pausing management of one PR
Remove the opt-in label from the PR on Forgejo. The next reconciliation
tick (within CONTROLLER_RECONCILIATION_INTERVAL_S, default 300s)
detects the missing label and transitions the workflow to ABANDONED
with reason opt-in-label-removed. The controller stops touching it.
To re-enable: re-add the label. Next discovery tick rediscovers it as a new DISCOVERED workflow.
Adding capacity
Just enable more worker units:
sudo systemctl enable --now cleveragents-controller-worker@implementer-2
The dequeue protocol is FOR UPDATE SKIP LOCKED on Postgres, so adding workers scales linearly without lock contention.
Restarting cleanly
- Master:
sudo systemctl restart cleveragents-controller-master. The SIGTERM handler flips the stop_event; the current tick finishes; the loop exits within 30s. - Workers:
sudo systemctl restart cleveragents-controller-worker@implementer-1. In-flight attempts get a graceful stop via the heartbeat thread ceasing — the master's reaper resets the attempt to pending after the TTL. New workers pick it up.
Incident response
"A workflow is STUCK"
- Find it:
SELECT workflow_id, owner, repo, entity_number, current_state, last_transition_at FROM workflows WHERE current_state = 'STUCK'; - Inspect the most recent event row for context:
SELECT ts, event_type, from_state, to_state, payload FROM controller_events WHERE workflow_id = <id> ORDER BY ts DESC LIMIT 10; - Common reasons:
pickup-exhausted— the attempt was reset to pending too many times (default 3,CONTROLLER_PICKUP_GUARD_MAX_PICKUPS). Cause: workers are dying mid-attempt. Check worker logs around the attempt'screated_at.opt-in-label-removed— operator removed the label. Re-add it on Forgejo or treat this as intentional abandonment.pr-not-found-on-forgejo— Forgejo returned 404 during reconciliation. PR may have been deleted; verify by hand.branch-protection— merge attempt hit a branch-protection rule the bot account isn't allowed to bypass. Fix on Forgejo.
- To unstick a workflow (operator action):
The next tick will schedule a fresh implementer attempt.
UPDATE workflows SET current_state = 'IMPLEMENTING', last_transition_at = NOW(), entered_state_at = NOW() WHERE workflow_id = <id>; INSERT INTO controller_events (workflow_id, ts, event_type, from_state, to_state, payload, forgejo_write_pending, replay_attempts) VALUES (<id>, NOW(), 'operator-unstick', 'STUCK', 'IMPLEMENTING', '{"reason":"<short reason>","operator":"<your name>"}', 0, 0);
"MERGING is stuck"
Look for merging_retry_count >= 5 in the workflows table — that
means the merge endpoint has 5xx'd 5 times in a row and the master
gave up. Inspect the controller_events payload for the actual HTTP
error. Most common cause: Forgejo restart mid-merge — just operator-
unstick to retry once Forgejo is back.
"Workers are dequeuing but nothing happens"
Check the OpenCode server first — workers depend on a healthy
OpenCode at CONTROLLER_OPENCODE_URL. The worker log will show
"OpenCode transport error" or "OpenCode session timed out" for each
failed attempt.
"Multiple machines, but one machine takes all the work"
Confirm the dequeue is using FOR UPDATE SKIP LOCKED:
EXPLAIN UPDATE workflow_attempts SET status='in_progress', ...
WHERE attempt_id = (SELECT attempt_id FROM workflow_attempts
WHERE status='pending' ORDER BY created_at
FOR UPDATE SKIP LOCKED LIMIT 1);
On SQLite (single-host dev), the lock is BEGIN IMMEDIATE — multiple machines pointed at the same SQLite file will serialize and one machine will appear to monopolize. This is expected; switch to Postgres for multi-machine.
Database recovery
The controller's DB is the source of truth — Forgejo is the journal. If the DB is lost:
- Restore from your Postgres backup if you have one.
- Otherwise: start fresh. Master backfill will discover every open PR/issue with the opt-in label. Active workflows lose their attempt history — they re-enter at DISCOVERED → ANALYZING. No Forgejo state is lost (PRs + comments + reviews are intact).
Migration playbook (Phase 2)
Goal: bring one PR under controller management without exposing the broader repo.
- Pick a low-risk PR with no active human reviewers.
- Add the
controller-managedlabel on Forgejo. - Watch the master log — next discovery tick rediscovers it as DISCOVERED. The state machine drives ANALYZING → IMPLEMENTING.
- Monitor the controller_events table for transitions:
SELECT ts, from_state, to_state, payload FROM controller_events WHERE workflow_id = ( SELECT workflow_id FROM workflows WHERE entity_number = <pr_number> AND kind='pr' ) ORDER BY ts; - If anything goes wrong: remove the label. The next reconciliation tick ABANDONS it cleanly.
- After a successful run, add the label to a second PR. Etc.
When you're confident, run with --no-opt-in-label to manage all
PRs unconditionally.
Tunables cheat sheet
| Variable | Default | When to change |
|---|---|---|
CONTROLLER_MASTER_TICK_INTERVAL_S |
30 | Lower for snappier responses; raises DB load |
CONTROLLER_REAPER_INTERVAL_S |
60 | Match to your worker heartbeat (default 30s) ×2 |
CONTROLLER_RECONCILIATION_INTERVAL_S |
300 | Lower if operators frequently modify PRs out-of-band |
CONTROLLER_WORKER_POLL_INTERVAL_S |
5 | Lower for snappier dequeue; raises DB load |
CONTROLLER_HEARTBEAT_INTERVAL_S |
30 | Must be ≪ lock_ttl_seconds (master writes 600s) |
CONTROLLER_MAX_CONCURRENT_WORKER_THREADS_PER_MACHINE |
1 | Pool-thread count inside the one worker process (not extra processes); bump toward your OpenCode per-instance concurrency ceiling |
CONTROLLER_TIER_*_TIMEOUT_S |
600 / 1200 / 1800 | Bump if tier-2 attempts hit the wall on large PRs |
CONTROLLER_OPT_IN_LABEL |
controller-managed |
Rename if you collide with an existing label |
What this controller does NOT do (yet)
- Replace stub CI parsers (robot_framework / bandit / semgrep / vulture
/ radon / slipcover / build). Those gates emit
parser-pending-*CIFailures and don't extract structured findings yet — the raw log is still surfaced to the implementer. - HA (multi-master). The plan is one master per (owner, repo); HA via lease-based leadership is deferred.
- Per-PR custom configs (different tier ceilings, custom timeouts). Everything is global env config in v1.
Phase 2 trial — known gaps to handle by hand
The controller infrastructure can drive a PR from DISCOVERED → IMPLEMENTING → AWAITING_CI, but two pieces are stubbed and need operator intervention for end-to-end validation:
MCP-to-OpenCode transport (response-builder MCPs not yet wired)
The controller's response-builder MCPs (implementer-builder etc.)
aren't registered in .opencode/opencode.json, so the agent can't
call implementer_finalize via MCP. The prompt builder gives the
agent a FALLBACK: write JSON output to
{workspace_dir}/{role}_output.json directly via the Write tool.
The controller polls that path. Trial impact: agents that follow
the fallback instruction work fine. If an agent ignores the fallback
- tries the MCP path, the attempt times out after
finalize_timeout_s(default 30s) and gets recorded asworker-internal-error. Watch the log for "did not emit canonical output within Ns" messages.
CI status polling — autonomous (no manual SQL needed)
The master loop runs run_ci_status_poll_tick every
CONTROLLER_CI_STATUS_POLL_INTERVAL_S (default 60s). For each
workflow in AWAITING_CI, it calls Forgejo's
/commits/{sha}/status combined-status endpoint and applies the
matching state transition:
| Forgejo state | Event fired | Next state |
|---|---|---|
| success / neutral / skipped / warning | ci_green |
REVIEWING |
| failure / error / cancelled / timed_out / stale | ci_red_retry_same_tier |
IMPLEMENTING |
| pending / queued / in_progress / action_required | (no-op) | stays AWAITING_CI |
Operators no longer need the manual SQL workaround. If CI hangs
permanently (broken integration, runner outage), the
ci_polling_exhausted timeout (default 2h) still fires as the
safety net → STUCK.
To tune for trial speed:
CONTROLLER_CI_STATUS_POLL_INTERVAL_S=10 # poll every 10s
CONTROLLER_AWAITING_CI_TIMEOUT_S=300 # 5-min timeout instead of 2h
Trial checklist
- Set FORGEJO_URL + FORGEJO_TOKEN + CLEVERAGENTS_DB_URL on master
- worker hosts.
- Pick a low-risk PR; add the
controller-managedlabel. - Start the master:
systemctl start cleveragents-controller-master. - Watch the journal:
journalctl -u cleveragents-controller-master -f. - Expect within ~30s:
- Discovery picks up the PR (DiscoveredEntity logged)
- Promoter advances DISCOVERED → ANALYZING
- Scheduler enqueues an estimator attempt
- Worker dequeues + spawns the estimator agent
- Expect within tier-0 timeout (~10 min):
- Estimator emits JSON → ANALYZING → IMPLEMENTING
- Scheduler enqueues an implementer attempt
- Worker spawns the implementer agent
- Implementer commits + pushes → workflow → AWAITING_CI
- CI runs in Forgejo Actions; the master's CI-status poller fires
every 60s (or whatever you set
CONTROLLER_CI_STATUS_POLL_INTERVAL_Sto) — when CI goes green the workflow advances to REVIEWING automatically. - The reviewer attempt runs; if
verdict='approve', the workflow transitions to MERGING and the controller calls Forgejo's merge endpoint. - Confirm MERGED state via DB:
SELECT current_state FROM workflows WHERE entity_number = <pr>;