Adds a long-lived sidecar (pr_state_warmer.py) that polls Forgejo's /pulls endpoint every 30s and writes the full PR snapshot to a shared SQLite store, eliminating the dispatcher's per-cycle cold-cache stalls (24-30s rebuilds on flaky cycles) and the silent 50-PR pagination cap on the legacy single-page fetch. Substrate - tools/_pr_state_cache.py — SQLite store with (owner, repo) PK, WAL mode, additive v2→v3 migration (comments_refreshed_updated_at), bounded fcntl.flock migration lock, threading.Lock for per-process init, @_with_reheal decorator (catches OperationalError no-such- table + DatabaseError corruption with file quarantine), atomic TEMP-table chunking for >32k seen-set, _normalize_updated_at to canonicalize Forgejo tz-marker drift - tools/pr_state_warmer.py — poll/upsert/vanish/comments-refresh loop with fcntl.flock singleton (rejects second warmer), bounded comments-refresh cap, persistent deferral via SQL pending query, PermissionError-tolerant lock setup, cold-start log suppression - tools/_pr_classification_cache.py — three-layer fall-through (warmer cache → list cache → live fetch) with staleness gate (PR_STATE_WARMER_STALE_AFTER_S floored at 30s in prod) Comments cache hardening - Bot-filter at write time drops bot status/claim/release/sentinel while preserving **Implementation Attempt** markers (94.6% reduction on bot-heavy PRs like #30's 19k-comment thread) - _normalize_since_cursor strips microsecond precision before building ?since= query (fixes the live-observed Forgejo HTTP 422 bug on PRs #25 + #28); handles uppercase Z, lowercase z, ±HH:MM offsets (including non-zero like +05:30), naive ISO - Lazy migration of legacy null-key by_author entries on _read_cache - _newest_cursor walks tail-back skipping malformed entries Supporting infrastructure (cumulative dmpipeline-v2 work) - Telemetry server: SSE live tail, run-sessions enumeration, cost/token tracking, app.js UI rewrite with collapsible sections - MCP servers (mcp_ci_server, mcp_forgejo_server, mcp_git_server, mcp_handoff_server, mcp_graphify_server) for opencode worker context access - Live log writer (tools/live_log_writer.py) — SSE-streaming dispatcher event log - Tier-dispatcher escalation flow with prompts trimmed for budget - Shared bot-logins resolver (tools/_bot_logins.py) replacing two drift-prone copies - token_usage_audit.py for opencode cost analysis Tests - 2259 passing across 65 changed/new files - New suites: test_pr_state_cache, test_pr_state_warmer, test_pr_state_warmer_integration, test_pr_classification_cache, test_pr_list_cache_backoff, test_mcp_* (5 servers), test_live_log_writer_sse, test_telemetry_run_sessions, test_review_post_ready_label - Test_pr_comments_cache expanded with bot-filter coverage, cursor-normalization regression pins, format-drift, atomicity, failed-comments-not-stamped (silent-data-loss class) - Parametrized @_with_reheal coverage across 7 wrapped APIs - Real fault-inject atomicity test for chunked mark_vanished path via Connection wrapper class - Subprocess-based singleton flock test (cross-process contract) - Event-driven SIGTERM-mid-poll test (no fixed-sleep flake) Architecture notes - Schema v3 migration is additive (ALTER ADD COLUMN); v0/v1 still need destructive rebuild because pre-v2 column shape lacks owner/repo. Cross-process drop-table-ping-pong prevented by the fcntl migration lock + per-process _initialized flag. - Comments-refresh deferral is persistent via comments_refreshed_updated_at column — survives warmer restart, picks up next cycle even if PR didn't change again. Replaces in-memory changed_numbers list. - Rollback path: PR_STATE_WARMER_PREFER=0 bypasses the warmer cache and reverts to live-fetch behavior. PR_STATE_CACHE_DISABLE=1 short-circuits the warmer process at startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
73 KiB
AGENTS.md — Agent Knowledge Base for cleveragents-core
This file captures persistent knowledge that AI agents should retain across sessions.
Repository
- Forgejo (Git) remote:
ssh://git@git.cleverthis.com/cleveragents/cleveragents-core.git - Canonical repo path:
cleveragents/cleveragents-core - Default branch:
master
Forgejo / Gitea API Access
The project uses a self-hosted Forgejo instance (compatible with the Gitea REST API).
Connection Details
| Property | Value |
|---|---|
| Base URL | https://git.cleverthis.com |
| API root | https://git.cleverthis.com/api/v1 |
| Token env var | GITEA_TOKEN (in .devcontainer/.env) |
Authentication
Pass the token as a header on every request:
Authorization: token <GITEA_TOKEN>
Common API Endpoints
# List repos the token can access
GET /api/v1/repos/search?limit=50
# Get a specific repo
GET /api/v1/repos/{owner}/{repo}
# List merged PRs (paginated, 50 per page)
GET /api/v1/repos/{owner}/{repo}/pulls?state=closed&limit=50&page={n}
# Get a single PR
GET /api/v1/repos/{owner}/{repo}/pulls/{index}
# List commits on a branch
GET /api/v1/repos/{owner}/{repo}/commits?sha=master&limit=50&page={n}
# Get repo topics/labels/milestones
GET /api/v1/repos/{owner}/{repo}/labels
Shell Example (curl)
TOKEN=$(grep GITEA_TOKEN .devcontainer/.env | cut -d'"' -f2)
curl -s -H "Authorization: token $TOKEN" \
"https://git.cleverthis.com/api/v1/repos/cleveragents/cleveragents-core/pulls?state=closed&limit=50&page=1"
Python Example
import urllib.request, json
TOKEN = "..." # from .devcontainer/.env
BASE = "https://git.cleverthis.com/api/v1"
HEADERS = {"Authorization": f"token {TOKEN}"}
def api_get(path):
req = urllib.request.Request(f"{BASE}{path}", headers=HEADERS)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
# Fetch all closed (potentially merged) PRs
page, all_prs = 1, []
while True:
prs = api_get(f"/repos/cleveragents/cleveragents-core/pulls?state=closed&limit=50&page={page}")
if not prs:
break
all_prs.extend(prs)
if len(prs) < 50:
break
page += 1
merged = [pr for pr in all_prs if pr.get("merged_at")]
Notes
- A PR is considered merged when
merged_atis non-null in the API response. merged_bymay benullin older records; fall back touser.login(the author) when absent.- The API is Gitea-compatible — all standard Gitea/Forgejo API docs apply.
- The Forgejo web UI is at
https://git.cleverthis.com/cleveragents/cleveragents-core.
CRITICAL: How to Correctly Count Merges into Master
Always use tools/count-master-merges.py.
Never hand-roll a merge query.
This script is the canonical single source of truth for every question of the form:
- "How many PRs merged today / in the last N hours / since timestamp X?"
- "Who merged what?"
- "What landed on master since my last check?"
The PR API by itself cannot answer these questions — squash and rebase merges produce plain commits on master with no merge marker, and the default closed-PR sort order hides recently-merged low-numbered PRs. The script handles all of this.
Usage
# Last 48 hours (human-readable) — delta-syncs the local cache, then reads
python3 tools/count-master-merges.py --hours 48
# Since an absolute UTC timestamp
python3 tools/count-master-merges.py --since 2026-04-24T04:00:00Z
# Machine-readable, for composing with other tools
python3 tools/count-master-merges.py --hours 24 --format json
# Full detail per event
python3 tools/count-master-merges.py --hours 6 --format detailed
# Bypass the cache and hit the API directly (slower; for cross-validation)
python3 tools/count-master-merges.py --hours 48 --no-cache
# Use existing cache without re-syncing (instant; fine for repeated reports)
python3 tools/count-master-merges.py --hours 48 --no-sync
The script reads GITEA_TOKEN from the environment or from
.devcontainer/.env (or .env) automatically. It emits the count of PR merges,
direct pushes, raw commits, a chronological merge list with merged_by, and a
per-actor breakdown.
Local cache & delta sync
Every invocation syncs a local SQLite cache at tools/.cache/forgejo.sqlite
(gitignored) before reading, pulling only deltas from the Forgejo API:
- New master commits since the last sync's HEAD.
- PRs whose
updated_atis newer than the last-sync cursor. - PR detail (
merged_by,additions,deletions,changed_files) is fetched lazily on first read and cached permanently. compare/master...{sha}reachability results are cached too.
The shared cache is managed by tools/_forgejo_cache.py
and consumed by both count-master-merges.py and pr-stats.py.
Companion script: tools/pr-stats.py
Use tools/pr-stats.py — never hand-roll — for these five
PR-lifecycle metrics:
- Commits to master
- New PRs opened (
created_atin window) - Total PRs closed (
closed_atin window) - PRs merged (closed +
merged=true) - PRs closed without merging
Each run asserts four cross-validation invariants (I1–I4) and aborts silently
if any fail; metric #4 is also cross-checked against count-master-merges.py
when invoked with --cross-check-merges. It shares the same SQLite cache as
count-master-merges.py and supports the same --no-cache / --no-sync flags.
python3 tools/pr-stats.py --hours 48
python3 tools/pr-stats.py --windows 24h,48h,7d,30d
python3 tools/pr-stats.py --weekly 12 --format json
Canvas renderer: tools/render-pr-velocity.py
Use tools/render-pr-velocity.py — never
hand-edit — to refresh pr-velocity.canvas.tsx. The script is the canonical,
LLM-free path from cache state → canvas file: delta-sync the local Forgejo
cache, compute every chart/table/stat deterministically (via the same primitives
as pr-stats.py and count-master-merges.py), generate narrative auto-commentary
from the numbers, and write the canvas via a {{KEY}} template at
tools/pr-velocity.canvas.template.tsx.
Measured on the current repo: ~13 s end-to-end with a delta sync, ~0.5 s with
--no-sync (cache already warm).
# Refresh the canvas in place (delta-sync + render)
python3 tools/render-pr-velocity.py
# Re-render from warm cache only (sub-second)
python3 tools/render-pr-velocity.py --no-sync
# Preview to stdout without writing
python3 tools/render-pr-velocity.py --no-sync --output -
# Write to an arbitrary path
python3 tools/render-pr-velocity.py --output /tmp/pr-velocity.canvas.tsx
Interactive menu: tools/reports.sh is the
unified Bash menu for refreshing any canvas or exporting it to PDF.
Top-level choices:
1) Refresh PR velocity canvas (once / every 15 min / hourly)
2) Refresh milestone completion canvas (once / every 15 min / hourly)
3) Print a report to PDF (sub-menu of discoverable canvases)
q) Quit
./tools/reports.sh
"Run once" returns to the main menu so you can chain operations
(e.g. refresh → export to PDF) in one session; continuous-loop refreshes
run until Ctrl+C. PDFs land in tools/pdf_reports/
(gitignored) and the menu prints the resolved path after each export.
Editorial overrides live in tools/pr-velocity-notes.toml (optional; see
tools/pr-velocity-notes.example.toml).
Any slot absent from the notes file falls back to the deterministic narrative
— so the canvas is 100% usable with zero human or LLM intervention. When an
event needs hand-written interpretation (major incident, surprising regression,
etc.), drop a few strings into pr-velocity-notes.toml and re-render.
The template and renderer are the only blessed path to pr-velocity.canvas.tsx.
If you need a new chart / metric / section, extend the renderer and the
template — never edit the generated canvas directly, as the next render will
stomp your changes.
Canvas renderer: tools/render-milestones.py
Use tools/render-milestones.py — never
hand-edit — to refresh milestone-completion.canvas.tsx. Same pattern as the
PR-velocity renderer, scoped to milestone status instead of PR flow:
- Pulls the milestone list, per-milestone open issue / closed issue counts, and due dates straight from the Forgejo API.
- Paginates
GET /repos/{owner}/{repo}/issues?state=closed&milestones=<v>&sort=newestfor each open milestone to count issues closed within a trailing window (default 14 days) and derive a linear ETA against the open backlog. - Pulls open-PR counts per milestone from
GET /repos/{owner}/{repo}/pulls?state=open. - Classifies risk deterministically (
done/critical/high/low) from overdue days + completion percentage + backlog size. - Auto-generates intro body, caveats callout, and Key-Findings paragraphs
from the numbers; fills the
{{KEY}}template attools/milestones.canvas.template.tsx.
# Refresh the canvas in place (default: 14-day velocity window)
python3 tools/render-milestones.py
# Use a tighter window for a more current signal
python3 tools/render-milestones.py --window-days 7
# Preview to stdout without writing
python3 tools/render-milestones.py --output -
Editorial overrides live in tools/milestones-notes.toml (optional; see
tools/milestones-notes.example.toml).
Every slot — intro paragraph, caveats body, Key-Findings JSX, and
per-milestone theme / description strings — is overrideable, but every slot
has a deterministic default, so the canvas is 100% usable with zero human or
LLM intervention.
The template and renderer are the only blessed path to
milestone-completion.canvas.tsx. For new sections or metrics, extend the
renderer and the template — never edit the generated canvas directly.
Canvas PDF exporter: tools/export-canvas-pdf.py
Use tools/export-canvas-pdf.py to turn any
*.canvas.tsx into a standalone PDF — useful for sharing a canvas report
outside Cursor (email, docs, printouts) without losing layout, tables,
stats, or charts. No LLM, no Cursor runtime required.
How it works:
- One-time vendor step. On first run, the tool downloads pinned copies
of React 18, ReactDOM 18, and Babel-standalone into
tools/.cache/canvas-pdf/(gitignored). Subsequent runs are fully offline. - Browser-side shim.
tools/_canvas_pdf_shim.jsre-implements everycursor/canvasexport — layout primitives (Stack,Row,Grid,Table,Divider), typography (H1/2/3,Text,Code,Link), containers (Card,CardHeader,CardBody,Callout), display (Stat,Pill,Button), form controls (TextInput,TextArea,Select,Checkbox,Toggle,IconButton), charts (BarChart,LineChart,PieChart— inline SVG), diff (DiffView,DiffStats), hooks (useHostTheme,useCanvasState,useCanvasAction), and all palette / token / typography constants. Unknown imports render a visible placeholder rather than crashing. - Preprocessor → Babel → Chrome. The Python driver rewrites
import { A, B } from "cursor/canvas"toconst { A, B } = window.CursorCanvas;, then hands the source to Babel-standalone for JSX/TS transpile in the browser. Headlessgoogle-chrome --print-to-pdftakes the snapshot.
# List the canvases currently available
python3 tools/export-canvas-pdf.py --list
# Export by short name (auto-located in the Cursor canvases folder)
python3 tools/export-canvas-pdf.py milestone-completion
# Export every canvas into a directory
python3 tools/export-canvas-pdf.py --all --output /tmp/canvas-pdfs
# Custom output file, dark theme, A4 landscape
python3 tools/export-canvas-pdf.py pr-velocity \
--output ~/reports/pr-velocity.pdf \
--theme dark --format A4 --landscape
# Keep the intermediate HTML for debugging
python3 tools/export-canvas-pdf.py milestone-completion --keep-html
Flags: --format (Letter / Legal / Tabloid / A3 / A4 / A5),
--landscape, --margin-mm, --scale, --wait-ms (bump if
charts look unfinished), --keep-html (debug),
--quiet, --canvas-dir (default: Cursor's canvases folder).
Requires google-chrome (or chromium) on PATH; one-time internet for
the vendor step. Pure Python otherwise.
Cache maintenance is also exposed directly via
tools/forgejo-sync.py:
# Pull deltas (fast, safe to run anytime)
python3 tools/forgejo-sync.py
# Truncate and resync from scratch (use after suspected force-push or schema drift)
python3 tools/forgejo-sync.py --full
# After a sync, back-fill merged_by detail for every merged PR (one-shot, permanent)
python3 tools/forgejo-sync.py --backfill
# Show cache stats without syncing
python3 tools/forgejo-sync.py --stats
Performance: on the current repo (2100+ commits, 1400+ PRs), a cold seed
takes ~2.5 minutes, a steady-state delta sync takes ~7 seconds, and a
12-week weekly breakdown drops from >5 minutes (uncached, times out) to
~0.4 seconds. If the cache is ever suspected of drift, delete
tools/.cache/forgejo.sqlite* and re-run — the scripts will self-seed.
All Paths Code Can Land on Master (reference)
The script accounts for all of these:
| Path | Commit message marker | Detection strategy |
|---|---|---|
| Forgejo UI merge commit | Merge pull request 'title' |
message match |
| Forgejo UI squash merge | none (plain commit) | SHA cross-reference |
| Forgejo UI rebase merge | none (N plain commits) | SHA cross-reference |
| Forgejo auto-merge | depends on strategy | above, as applicable |
CLI git merge → push |
Merge branch 'X' |
message match |
| CLI rebase / cherry-pick / direct push | none | fallthrough = direct push |
| Batch-merge PR | chore(merge): batch merge of N pull requests |
matched as merge commit |
| Integration-branch merge (PR base ≠ master, later merged to master) | varies | Phase 2 reachability check via compare/master...{merge_commit_sha} — if zero commits ahead, the PR's code is on master and emits an integration-branch-merge event |
Why the script is required (the two traps it solves)
-
Squash and rebase merges are invisible from commit messages alone. They land as plain commits indistinguishable from direct pushes. Example: PR #1228 was merged by HAL9000 via squash merge with commit message
fix(tests): update resource_dag.robot...— you cannot tell from the message that it was a PR merge. The script cross-references every commit SHA against the PR API'shead.shaandmerge_commit_shafields to catch these. -
The closed-PR API default sort hides recent merges of old PRs. Default order is PR number descending. A recently-merged low-numbered PR (e.g. #1228) would be on page ~400+ under default ordering. The script uses
sort=recentupdateso recently-merged PRs always appear on page 1 regardless of PR number.
If you ever find yourself writing a closed-PR scan loop or merge-marker regex by hand, stop and run the script instead. Every bug we've hit in merge counting has come from bypassing it.
Correctness guarantees (as of 2026-04-25)
The script is designed so that no PR merge or commit-to-master can be silently dropped. Specifically:
- Simultaneous merges are not collapsed. Commits are grouped by second-level committer timestamp, and each distinct PR in a group gets its own event. A previous minute-level grouping silently discarded the 2nd..Nth PR when multiple PRs merged in the same minute (61 events were lost in a single 30-day window under that bug).
- Rebase merges stay as single events. When exactly one PR is matched in a
second-group, every commit in that group is attributed to that PR regardless
of which specific commit SHA matched. N-commit rebase merges therefore emit
one event with
commit_count = N, not N separate events. - No closed-PR page cap within the window. The closed-PR scan uses
sort=recentupdateand does not stop until two consecutive full pages contain zero in-window merges. There is no fixed 30-page ceiling. - No early-exit on out-of-order commits. The commits scan continues until an entire page falls outside the window, so a single commit returned out of strict chronological order cannot truncate the scan.
- Unparseable timestamps are warned, not silently dropped.
- Network flakes are retried with exponential backoff (3 tries).
- Integration-branch merges are counted. Phase 2 checks every in-window
closed+merged PR whose SHA didn't surface on the master commit stream, and
emits an
integration-branch-mergeevent whencompare/master...{sha}confirms the PR's code is reachable from master HEAD. This captures PRs whosebase.ref != 'master'(integration branches later merged to master via fast-forward or merge-commit) as long as the PR closed and its code actually landed on master.
Known theoretical miss paths (what the script still cannot detect)
These are fundamental API limitations, not script bugs:
- Force-pushes to master rewrite history; any commit removed from master is gone from the commits endpoint and cannot be counted. (Expected behavior.)
- In-flight race during scan. If a new commit is pushed to master while the script is paginating through commits, the page boundary shifts and the bottom of one page may be missed. Re-run the script to pick up anything missed; real incidence is negligible outside extreme push rates.
- Cherry-picked commits produce a new SHA on master that does not match
any PR's
head.shaormerge_commit_sha. They are correctly classified as direct pushes. If the question is "did this PR's code land on master?" via cherry-pick, the answer is yes but the PR will not appear in the list. - Integration branch merged via squash/rebase to master. If an integration
branch was squashed or rebased into master (rather than fast-forwarded or
merge-committed), the integration branch's commits — including each
sub-PR's
merge_commit_sha— are orphaned and replaced by new SHAs on master. The Phase 2 reachability check correctly reports those PRs as "not on master" because, technically, their specific commit SHAs are no longer reachable from master even though the code is. In that case the integration PR itself is counted (it has amerge_commit_shathat IS on master), but the individual sub-PRs are not. This is the same pattern as a batch-merge PR and is correct behavior: we cannot attribute code that was squashed/rebased across layers without ambiguity. - Multi-PR, same-second merges with mixed squash + rebase. If PR A (squash, 1 commit) and PR B (rebase, N commits) commit in the same second, the script attributes 1 commit to PR A, 1 commit to PR B (its head), and the remaining N-1 rebased commits become a separate direct-push event. Neither PR is missed, but the direct-push count is slightly inflated in this edge case. Not observed in any historical window to date.
- Merged-but-open PRs. The closed-PR scan uses
state=closed. Gitea merge is atomic (merge → close) so this is not reachable in practice.
Merge invariant (auto-agents pipeline)
The auto-agents merge pipeline is governed by a hard correctness invariant
captured by the deterministic merge driver in
tools/merge_drive.py. The plan is described in
detail in
.cursor/plans/improve_auto-agents_pipeline_a31d3945.plan.md;
this section captures the operational summary that agents need to read on
every session.
The invariant (non-negotiable)
Every commit that lands on master must come from a SHA whose CI passed against the exact current master. Specifically:
- The PR is rebased onto current master HEAD.
- CI passes on the rebased head SHA against all required status contexts.
- Master must not have advanced between CI completion and the merge call.
- The merge call uses
head_commit_idas an optimistic lock so any push between our check and the merge fails the merge with HTTP 409.
If any of (1)–(4) is violated, two PRs that pass individually can still
combine into a broken master state. The continuous invariant verifier in
tools/verify_invariant.py audits master
every 15 minutes and opens an auto/invariant-violation issue on any
breach.
Why the LLM merge supervisor is decommissioned
merge_drive.py is the sole merger of PRs in this repo. The
historical pr-merge-supervisor / pr-merge-worker LLM pair has been
removed from the managed fleet in
.opencode/agents/auto-agents.md;
nothing in the repo launches it anymore. The decommissioning is mandatory
for the invariant to hold:
- The LLM-side
merge_pr.tscalls Forgejo's merge endpoint withmerge_when_checks_succeed=true. That accepts a rebased head SHA and lets Forgejo merge it whenever its CI clears, with no coordination with the driver's train-build, no bisect-on-failure, and no cooperation with theauto/claimed-mergeownership label. - Running both concurrently would race on the same PRs, void the
driver's train CI invariants (the LLM worker's automerge can fire
between the driver's "CI green" check and its merge call, advancing
master out from under the driver), and silently leak merges that
bypass
MERGE_DRIVER_RESTART_LIMIT, anti-starvation cooldowns, and theauto/needs-implementerescalation path.
The supervisor and worker prompt files (pr-merge-supervisor.md,
pr-merge-worker.md) and the underlying TypeScript scripts
(merge_pr.ts, rebase_pr.ts) are intentionally left on disk.
They are not referenced by any agent flow today, but they remain
available for two narrow, deliberate uses:
- The operator-facing
tools/claim_pr.py/claim_pr.tsflow (interactive, human-in-the-loop) shares theauto/claimed-mergelabel with the driver and is allowed to coexist. - Future implementations of the driver may shell out to
merge_pr.tsfor the actualPOST /pulls/<n>/mergecall rather than re-implement that HTTP interaction.
Do not re-add pr-merge-supervisor to auto-agents.md's managed
fleet. The note in that file explains the risk inline so the
constraint is visible at the point of edit.
Tier 1 mutual-respect claim contract (2026-05-04)
The merge driver's auto/claimed-merge contract has been extended to a
three-way symmetric mutex so reviewer / implementer workers never
comment on PRs being merged, and the merge driver never rebases a PR
that an implementer or reviewer is actively working on. The contract is:
- Workers claim before working.
pr-review-worker.md(Startup step 3) andimplementation-worker.md(Startup step 5, only whenwork_type=pr_fix) invokeclaim_pr.tswith--action claimand a kind-specific TTL (1800 s for reviewer, 7200 s for implementer). The script is idempotent; re-claiming an already-claimed PR is a soft no-op. - Workers release before exiting. Both worker prompts have a
"Release and Exit" subsection that funnels every termination path
(success, early-exit, dispatcher failure) through a single
claim_pr.ts --action releasecall. - Wrappers exclude claimed PRs. The 5 reviewer and 2 implementer
list_prs_*wrapper scripts in.opencode/skills/auto-agents-system/scripts/passexcludeClaimed: trueso any PR carrying any of the threeauto/claimed-*labels is hidden from the corresponding supervisor pool. This is the load-bearing fix for the "comments on a mid-train PR" race — oncemerge_drive.pyaddsauto/claimed-merge, the reviewer / implementer pools cannot pick that PR. - Driver sweeps every claim label.
merge_drive.py:sweep_all_expired_claimsruns at the start of every cycle and releases stale claims for all three labels (not justauto/claimed-merge). If a worker crashes between claim and release, the label is released after the TTL so the PR re-enters the candidate pool — the system is self-healing. The cycle output dict carriesswept_by_labelso operators can see per-label orphan rates in the cycle log. - Bash + skill permissions are explicit. Both worker prompts gain
one bash allow (
npx --yes tsx*claim_pr.ts*) and one skill allow (auto-agents-system). The pre-existing*api/v1/repos/*/labels*: denyrules are unchanged — the issue-level labels endpoint (/issues/{n}/labels) used by claim/release is not matched by that pattern.
The contract assumes both workers correctly run the release step on every termination path. The TTL sweep is the safety net, not the primary release mechanism — under normal operation, claims are released within seconds of work completing, not at the end of the TTL window.
This is the minimum viable extension of the deterministic merge driver to the reviewer / implementer pools.
Tier 2 deterministic dispatchers (2026-05-06)
The reviewer and implementer queues now have host-level Python dispatchers:
tools/dispatch_review.pypolls the same five review work groups the decommissionedpr-review-supervisorused, claims PRs withauto/claimed-reviewer, invokespr-review-workerthrough OpenCode HTTP, releases the claim, and recordsdispatch_review_cyclestelemetry.tools/dispatch_implementer.pypolls failing-CI PRs, unaddressedREQUEST_CHANGESPRs, then open issues, claims PR work withauto/claimed-implementer, invokesimplementation-worker, releases the claim, and recordsdispatch_implementer_cyclestelemetry. Issue implementation is not claimed because no PR exists yet.
The LLM workers still perform the irreducible work: code review judgment
and code generation. The dispatchers replace the long-running LLM
supervisor loops (implementation-supervisor.md and
pr-review-supervisor.md, both decommissioned 2026-05-09) with
deterministic queueing, liveness, claim, timeout, and telemetry
behavior. The legacy supervisor agent files are gone from
.opencode/agents/ and the dispatchers no longer carry the
coexistence-refusal guards that briefly bridged the two layers — the
hard-switch was an explicit operator decision (see CHANGELOG.md
2026-05-09 entry).
Claim ownership semantics. Forgejo's label-add API is idempotent —
it returns 200 whether the label was newly attached or already
present — so the dispatchers cannot infer ownership from the add call
alone. Before each claim, _dispatch_runtime.claim_work_item GETs the
issue's labels and refuses (terminal_state="already-claimed") when
any auto/claimed-* label is already attached. This means a PR
already held by the merge driver, the conflict driver, or a sibling
dispatcher is left untouched: the worker is not invoked and the
release path is not run, so we never strip a claim we did not just
attach. The pre-check has the same TOCTOU window as claim_pr.ts
(documented in conflict-drive-plan.md § 3.3.1); the worker layer's
idempotent claim helper deduplicates work in the rare racing case.
Worker session outcomes. The dispatchers call
_opencode_worker.run_session_blocking, an outcome-agnostic entry point
whose SessionResult.status is one of completed / timeout /
transport-error. Reviewer / implementer workers do not need to
emit the conflict-driver {outcome: resolved | unresolvable | rebase-failed} JSON to be classified as successful — a clean session
exit is sufficient. The conflict driver still uses
run_worker_blocking, which thin-wraps the session runner and adds the
JSON outcome classification.
Cycle failure budget. Each dispatcher's outer loop tracks
consecutive cycle exceptions against cycle_failure_budget (default 5,
overridable via REVIEW_DISPATCHER_CYCLE_FAILURE_BUDGET /
IMPLEMENTER_DISPATCHER_CYCLE_FAILURE_BUDGET). Transient failures —
tsx-time exceptions, malformed JSON from a work-group script, Forgejo
4xx blips — are logged and the loop continues. Once the budget is
exhausted the driver exits with code 2 so a supervising launcher
(systemd, opencode-builder.sh, etc.) can restart it with fresh
state, matching the behavior of merge_drive.py and
conflict_drive.py.
The supervisor side of that contract ships in this repo as
scripts/dispatchers-launcher.sh and
contrib/systemd/cleveragents-dispatchers.service.
The launcher reaps each child, restarts on non-zero exit with backoff,
enforces a per-child crash-loop limit
(DISPATCHERS_RESTART_LIMIT × DISPATCHERS_RESTART_LIMIT_WINDOW_S),
and forwards SIGTERM for a clean shutdown so a systemctl stop lands
as success.
Claim-time terminal states. In addition to the session lifecycle states above, the dispatcher records:
claim-failed— Forgejo refused our label-add (e.g., the label is not defined on the repo).already-claimed— another worker already holds anauto/claimed-*label on the item; we did not claim and did not release.labels-fetch-failed— the labels GET returned non-200 (auth / permission / repo-gone). Treated as a foreign claim: refused rather than silently attaching a label we have no permission to manage. The HTTP status is captured inclaim_result.label_fetch_statusfor operator triage.
Long-worker liveness contract (2026-05-07). The dispatcher's
heartbeat is refreshed inside the OpenCode polling loop, not just
between cycles. _dispatch_runtime.dispatch_one passes an on_poll
callback to _opencode_worker.run_session_blocking; the callback
fires once per status-poll iteration (every ~2 s) and writes the
heartbeat file. Without this, a 30-minute review session would leave
the heartbeat file untouched, and
scripts/dispatchers-launcher.sh
/ cleveragents-dispatchers.service
would treat the dispatcher as hung and SIGTERM it mid-cycle —
orphaning the OpenCode session and the auto/claimed-* lock.
Callback exceptions are logged and swallowed inside
run_session_blocking, so a transient heartbeat-write failure (disk
full, EROFS, EACCES) cannot mask a successful worker completion.
The same wiring is plumbed into tools/conflict_drive.py for the
conflict-resolver-worker invocation.
In-flight cycle row visibility (2026-05-07, schema v5). The Tier
2 cycle tables (dispatch_review_cycles,
dispatch_implementer_cycles) carry a row from cycle start, not
from cycle end. _dispatch_runtime.run_one_cycle calls
begin_cycle (INSERT with ended_at IS NULL) before any worker
dispatch and finish_cycle (UPDATE) in a try/finally so a crash
mid-cycle still resolves the row. The schema migration drops the
ended_at NOT NULL constraint and adds a UNIQUE INDEX on
cycle_id; pre-existing rows are preserved verbatim. The
/api/health telemetry endpoint surfaces the most-recent in-flight
cycle per dispatcher (in_flight_cycle: {cycle_id, started_at, session_id, elapsed_s}) and a running_long_worker flag that
fires when the heartbeat is older than 600 s AND a matching pid is
alive — points at a callback regression rather than a normal long
worker. The Drivers and Overview tabs render in-flight rows with a
tinted background and an "in flight" pill so a stuck cycle is
impossible to miss.
Cross-cutting agent instructions (2026-05-07)
opencode.json carries an
instructions array
that OpenCode appends to every session's system prompt — every agent,
every subagent, every tier dispatcher. Use it for rules that must hold
uniformly across the fleet so we do not have to copy/paste the same
section into 42 agent definition files.
The wired location in this repo is .opencode/instructions/*.md. Each
file in that directory is a single, focused rule set:
.opencode/instructions/bash-commands.md— explains how the OpenCodebashpermission engine matches against the raw, unexpanded command string and why chained commands (&&,||,;,|), command substitution ($(...)), and bare variable assignments (WORK_DIR=...) always fall through to the default deny rule. Includes wrong / right examples and a recovery recipe forpermission deniederrors.
When you add a new file under .opencode/instructions/, the existing
glob in opencode.json picks it up automatically — no further wiring
needed. OpenCode reads instructions at session creation, so a
running OpenCode server must be restarted (or the file must already
exist before its agent sessions are spawned) for changes to take
effect. Restart the OpenCode server after adding or editing files
here.
When to add a file here vs. updating an individual agent: if the
rule applies to every agent that uses the affected tool (bash, edit,
webfetch, etc.) put it here. If it is specific to a single agent's
domain (e.g., the reviewer's review-format requirements), keep it in
that agent's .opencode/agents/<name>.md body.
Tier 0A audit findings (2026-05-02)
The audit in tools/forgejo_audit.py was run
against cleveragents/cleveragents-core and produced these findings:
dismiss_stale_approvalsis alreadyfalseon the master branch protection rule. 1A is a no-op; the reapprove-if-clean-rebase fallback is not required. No follow-up todo is generated.- Canonical merge-bot identity is
Forgejo <forgejo@cleverthis.com>. Any commit on master whose committer email is anything else is a direct push (Path B revert candidate, or unauthorized — 2E alerts unconditionally). merge_pr.tssetsmerge_when_checks_succeed=trueunconditionally. This is the documented invariant-violation risk — verified empirically in 0A as part of pre-condition P1.default_delete_branch_after_mergeisfalseon the repo. The merge driver andtools/migrate_to_new_driver.pysweep abandonedauto/train/<ts>branches older than 2 hours.- Required CI contexts on master include:
CI / lint,CI / typecheck,CI / unit_tests,CI / integration_tests,CI / e2e_tests,CI / coverage, plusCI / build,CI / docker,CI / quality, andCI / security(with wildcards). The driver fetches this list frombranch_protections/masterat startup and refreshes every 15 min — it does not hardcode the set. push_whitelist_usernamesis[freemo]— only Jeff can direct-push to master. HAL9000 / HAL9001 cannot, by branch protection.
To re-run the audit: python3 tools/forgejo_audit.py. The empirical
pre-conditions (P1, P2, P5) require live test PRs and are listed in the
script's report output for manual verification — they are intentionally
not automated against the production repo.
Path A vs Path B revert procedures
When 2E opens an auto/invariant-violation issue, the bad commit must be
backed out:
- Default — Path A (revert PR through 0B). Open a new PR with
git revert <bad_sha>against master, labelledauto/revert. The operator (typically Jeff) approves the revert PR manually — no automated rubber-stamp. The driver picks it up on the next loop and merges it through the standard 0B path. Latency: one full cycle (~30-45 min). Invariant-clean: the revert is CI-validated against the broken master. - Operator override — Path B (direct revert push, emergency only).
Used when the operator suspects the merge driver itself is the cause of
the violation, or when Path A latency is unacceptable (e.g., master CI
is broken for everyone). Stop the driver
(
systemctl stop merge-driver), thengit revert <bad_sha> && git push origin masterfrom a privileged identity (Jeff's account, with branch-protection bypass). Restart the driver only after the violation root cause is understood. Latency: < 5 min. 2E will alert on the direct-push commit (it has no exemption logic). The operator manually closes the alert after confirming the revert worked, OR 2E's auto-close function (same cron) closes it automatically on the next 15-min tick once the bad commit's effect is no longer in master.
The Path B privileged identity is freemo (Jeff), per the push whitelist.
Driver environment variables
The merge driver in tools/merge_drive.py reads these environment
variables. All except GITEA_TOKEN are optional — sensible defaults are
baked in. The list mirrors _load_env() and Config in that file; if
you add a new env var to the driver, add it here too.
| Env var | Required | Default | Purpose |
|---|---|---|---|
GITEA_TOKEN |
yes | — | Forgejo PAT for the merge-bot identity used for read access and merge calls. Falls back to a GITEA_TOKEN=... line in ~/.cleveragents/forgejo-token if the env var is unset. |
FORGEJO_REVIEWER_PAT |
yes when MERGE_DRIVER_MAX_N >= 2 |
— | Separate PAT used to auto-approve umbrella train PRs (Tier 2D). Should belong to a reviewer-bot identity distinct from the merge-bot so the umbrella PR satisfies the "reviewer ≠ author" branch protection rule. |
MERGE_DRIVER_MAX_N |
no | 1 |
Max train size. 1 = single-PR mode (the safe rollout default). Raise to 2 only after 0B has been stable ≥ 1 week with zero auto/invariant-violation issues, then to 5, then to 10 once 1B telemetry shows clean-batch rate ≥ 70 %. |
MERGE_DRIVER_CI_TIMEOUT_S |
no | 3600 (1 h) |
Hard ceiling for waiting on CI of a rebased head SHA before releasing the PRs back to the pool with auto/ci-timeout. Re-tune to roughly 2 × P95(CI completion) once 1B has a week of data. |
MERGE_DRIVER_CI_POLL_INTERVAL_S |
no | 30 |
Sleep between status-check polls while waiting for CI on a rebased SHA. |
MERGE_DRIVER_POLL_INTERVAL_S |
no | 30 |
Outer-loop sleep when pick_candidates returned nothing. |
MERGE_DRIVER_LOCK_PATH |
no | merge-driver.lock |
Path for the single-instance fcntl.flock. Resolved with the fallback chain ${MERGE_DRIVER_LOCK_PATH} → ${XDG_RUNTIME_DIR}/merge-driver.lock → /tmp/merge-driver.lock. |
MERGE_DRIVER_HEARTBEAT_PATH |
no | merge-driver.heartbeat |
Liveness file the driver touches every loop iteration. Same fallback chain as the lock. |
MERGE_DRIVER_WORK_DIR |
no | ${TMPDIR}/merge-driver-work |
Persistent local clone the driver uses for rebases and fast-forward checks. Wiped and re-cloned on a corrupt-state error; never assume contents survive a restart. |
MERGE_DRIVER_COOLDOWN_MINUTES |
no | 30 |
How long a PR is excluded after being released as ci-timeout / restart-throttled. Anti-starvation: PRs that fail repeatedly are dropped to the back of the queue rather than blocking the train. |
MERGE_DRIVER_API_RETRIES |
no | 3 |
Retry attempts for idempotent reads. State-changing writes are retried at most once and only on connection-level errors. |
MERGE_DRIVER_REQUEST_TIMEOUT_S |
no | 30 |
Per-request timeout for every Forgejo API call. |
MERGE_DRIVER_RESTART_LIMIT |
no | 8 |
Max number of CI runs a single train cycle may invalidate (master-advanced restarts + 409 retries). When exhausted the cycle releases as restart-budget-exhausted and the constituent PRs receive auto/restart-throttled. |
MERGE_DRIVER_CLAIM_TTL_SECONDS |
no | MERGE_DRIVER_CI_TIMEOUT_S + 1800 |
TTL on auto/claimed-merge. The sweep_expired_claims pass at the start of every cycle releases any claim comment older than this so a crashed predecessor doesn't leave PRs stuck claimed. |
MERGE_DRIVER_LOG_LEVEL |
no | INFO |
Python logging level for merge_drive.py: DEBUG/INFO/WARNING/ERROR. Stderr is the destination unless a parent process (systemd, supervisord) has already configured the root logger. |
VERIFY_INVARIANT_LOG_LEVEL |
no | INFO (or DEBUG if --progress is passed) |
Python logging level for verify_invariant.py. The --progress CLI flag flips the default to DEBUG; setting this env var overrides both. |
The Tier 2 dispatchers follow the same conventions:
REVIEW_DISPATCHER_* and IMPLEMENTER_DISPATCHER_* cover
LOCK_PATH, HEARTBEAT_PATH, CYCLE_SECONDS,
MAX_ITEMS_PER_CYCLE, WORKER_TIMEOUT_SECONDS,
CLAIM_TTL_SECONDS, API_RETRIES, REQUEST_TIMEOUT_S,
SCRIPT_TIMEOUT_SECONDS, CYCLE_FAILURE_BUDGET, and LOG_LEVEL.
dispatch_review.py reads FORGEJO_REVIEWER_PAT (falling back to
GITEA_TOKEN for local tests); dispatch_implementer.py reads
FORGEJO_PAT (falling back to GITEA_TOKEN). Both read
OPENCODE_SERVER_URL (default
http://127.0.0.1:4096) and write heartbeat files through the same
fallback chain as merge_drive.py.
For deterministic-dispatcher runs that don't need the watchdog
session, start OpenCode in server-only mode:
OPENCODE_BUILDER_SERVER_ONLY=1 bash scripts/opencode-builder.sh.
That keeps the HTTP worker API available (the dispatchers need it to
run the LLM workers per work item) without spinning up the
auto-agents watchdog session at all. Skipping the watchdog is fine
in deployments where the host's process supervisor and log
aggregator are already monitoring dispatcher health.
Bot-generated commit conventions
The merge driver commits via the Forgejo API merge endpoint. Specific commits it generates (umbrella PR titles, train branch commits, etc.) are exempted from strict conventional-commit footer rules:
- Umbrella PR title format:
chore(merge): batch merge of N pull requests (#A, #B, #C). - Bot-authored commits (committer email is the merge-bot identity above)
do not require an
ISSUES CLOSED: #Nfooter — they are mechanical merge artifacts, not feature work. The reviewer worker's footer check exempts them via the committer-email match.
Operational tooling
These scripts implement the pipeline. Cross-link them so newcomers can
discover the system from AGENTS.md alone:
tools/forgejo_audit.py— read-only Tier 0A audit. Re-run before any rollout step to confirm Forgejo pre-conditions still hold.tools/setup_auto_labels.py— provisions theauto/*label set at org level. Idempotent; safe to re-run.tools/setup_branch_protection.py— Tier 0C: enforcespush_whitelist_usernames=[freemo]so only Jeff (and Path B reverts) can land commits on master without going through the driver.tools/audit_branch_protection.py— Tier 1A: dry-run / live flip ofdismiss_stale_approvals.tools/migrate_to_new_driver.py— Tier 0B.5 one-shot: clears staleauto/claimed-*labels, cancelsmerge_when_checks_succeedschedules, sweeps abandonedauto/train/<ts>branches > 2 h old.tools/merge_drive.py— Tier 0B + 2D: the deterministic merge driver. Single-instance lock + heartbeat.tools/conflict_drive.py— Tier 1.5: deterministic conflict-resolution driver. Owns theauto/needs-conflict-resolutionlabel, runsgit rebaseagainst master in a fresh clone, delegates to theconflict-resolver-workersubagent only when the rebase actually conflicts, force-pushes with--force-with-lease, escalates toauto/needs-implementerafter three definite failures in 24h. Single-instance lock + heartbeat.tools/dispatch_review.py— Tier 2: deterministic reviewer dispatcher. Polls existing review work-group wrappers, claimsauto/claimed-reviewer, invokespr-review-worker, releases claims, and recordsdispatch_review_cycles.tools/dispatch_implementer.py— Tier 2: deterministic implementer dispatcher. Polls existing implementer / issue work-group wrappers, claimsauto/claimed-implementerfor PR work, invokesimplementation-worker, releases claims, and recordsdispatch_implementer_cycles.tools/inject_synthetic_conflict.py— local-only test helper: opens a PR on a fork with a guaranteed add/add or modify-vs-stub conflict (modes:trivial,multi-commit,unresolvable). Used by Phase B fork-mode validation and the opt-intests/auto_agents/integration/test_conflict_resolution_e2e.py.tools/verify_invariant.py— Tier 2E: cron job (every 15 min) that audits new master commits and opens or auto-closesauto/invariant-violationissues.tools/flag_stale_prs.py— Tier 1C: flags PRs idle ≥ 28 days with unaddressedREQUEST_CHANGES. Assignsfreemoand appliesauto/stale-inactivity.tools/local_ci_gate.sh— Tier 1G: wraps the canonical 6-gatenoxpipeline (lint → typecheck → unit_tests → integration_tests → e2e_tests → coverage_report). Run before pushing any PR branch.--fastskips the slowest two gates for the inner loop; a final full run is still required before commit..opencode/skills/auto-agents-system/scripts/claim_pr.ts— Tier 2B: claim/release helper that the deterministic dispatchers (tools/dispatch_review.py,tools/dispatch_implementer.py) andtools/merge_drive.pyinvoke to mark a PR with anauto/claimed-{merge,implementer,reviewer}label and TTL comment. Operator workflows can also invoke it directly viatools/claim_pr.py.list_prs.tsand everylist_prs_*wrapper accept the matching--exclude-claimedopt-in flag (off by default).tools/setup_test_fork.py— sentinel testing: bootstrap a personal fork (creates the fork via the Forgejo forks endpoint, mirrors branch protection, provisionsauto/*labels at the repo scope). Idempotent.tools/duplicate_prs_to_fork.py— sentinel testing: duplicates the N most-recent open upstream PRs (head branch + body + safe-listed labels) into the personal fork undertests/sentinel-<N>-<safe-branch>and tags themauto/sentinel. Use to populate the fork before exercising the driver / verifier / preflight against an isolated workspace.tools/preflight_phase01.sh— pre-production validation harness; honoursFORGEJO_OWNER,FORGEJO_REPO,FORGEJO_API_BASEso the same script can be pointed at the canonical repo or a personal-fork test repo.
Targeting a personal-fork test repo
All Python pipeline tools (merge_drive.py, conflict_drive.py,
dispatch_review.py, dispatch_implementer.py, verify_invariant.py,
forgejo_audit.py, audit_branch_protection.py, flag_stale_prs.py,
setup_auto_labels.py, setup_branch_protection.py,
migrate_to_new_driver.py, inject_synthetic_conflict.py)
read FORGEJO_OWNER / FORGEJO_REPO /
FORGEJO_API_BASE (and FORGEJO_ORG / FORGEJO_DEFAULT_BRANCH /
FORGEJO_MERGE_BOT_EMAILS / FORGEJO_PUSH_WHITELIST where applicable)
at module-import time. Defaults preserve current canonical-repo
behaviour. Set them to retarget the entire stack:
export FORGEJO_OWNER=drew
export FORGEJO_REPO=cleveragents-core
export FORGEJO_PUSH_WHITELIST=drew
# all subsequent invocations now hit drew/cleveragents-core
The verifier's cursor file is automatically partitioned by
<owner>.<repo> so a fork run never clobbers the canonical cursor.
Forgejo API quirks observed during sentinel rollout
GET /repos/{owner}/{repo}/pulls?head=owner:branchis silently ignored by Forgejo (unlike GitHub). The endpoint returns every open PR. Code that needs to find a PR by head-branch must list pulls page-by-page and matchhead.refclient-side (seeexisting_fork_printools/duplicate_prs_to_fork.py).- Newly created forks return HTTP
202 AcceptedfromPOST /repos/<upstream>/forks— the fork is not queryable immediately.tools/setup_test_fork.py:wait_for_forkpolls untilGET /repos/<owner>/<repo>returns 200 before applying branch protection or labels. - Forks have
has_actions: falseandhas_issues: falseby default. Workflow YAML files at.forgejo/workflows/*.ymlare copied with the repo, but withhas_actions=falseno runs ever fire on push or pull_request. Withhas_issues=false,verify_invariant.pycannot fileauto/invariant-violationissues.setup_test_fork.pyPATCHes both totrueafter fork creation. The Actions API endpoint (/actions/runs,/actions/runners) returns HTTP 404 ("target couldn't be found") whilehas_actions=false, which is a useful signal — once it returns{"workflow_runs":[],"total_count":0}you know the runtime is on. - Required-check contexts use a wildcard convention. Forgejo
appends
(push)or(pull_request)to every status context name, so upstream's branch protection on master uses entries likeCI / lint*— the trailing*absorbs the event suffix. Thesetup_test_fork.pymirror logic preserves the wildcards verbatim;merge_drive.py:required_checks_passedmatches them viafnmatch. - Forgejo status field is
status, notstate. GitHub Statuses API usesstate; Forgejo usesstatus.merge_drive.pyreadss.get("status") or s.get("state")to handle both. New tools that consume statuses must do the same. - Forks do not inherit org-level labels.
setup_test_fork.pyalways provisions the canonicalauto/*set at the repo scope so the driver / reviewer / implementer can set/read their labels. - Upstream repository labels (e.g.
Type/Task) are not auto-created in the fork;duplicate_prs_to_fork.pyonly attaches labels that already exist in the fork. This is by design — sentinels need only theauto/*operational labels to exercise the pipeline.
Bootstrap env-var convention
setup_test_fork.py is the bootstrap for the test environment, so it
deliberately uses separate env vars from the rest of the pipeline
to avoid the chicken-and-egg ambiguity of "which repo are we
talking about right now":
| Var | Read by | Purpose |
|---|---|---|
FORGEJO_UPSTREAM_OWNER / FORGEJO_UPSTREAM_REPO |
setup_test_fork.py only |
The repo being forked from. Defaults to canonical cleveragents/cleveragents-core. CLI override: --upstream-owner / --upstream-repo. |
FORGEJO_OWNER / FORGEJO_REPO |
every other tool (driver, verifier, preflight, duplication, etc.) | The repo being operated on. Set to your fork (drew) when running fork-mode preflight or sentinel exercises. |
The intended workflow is:
# 1) Bootstrap the fork ONCE with drew's admin PAT (no .env override
# needed at this step — drew creates the fork and promotes HAL9000
# to admin via PUT /collaborators).
GITEA_TOKEN=<drew-admin-pat> tools/setup_test_fork.py
# 2) From here on, every downstream tool runs as HAL9000 via the
# launcher (matches Jeff's confirmed production env).
source tools/launch_fork.sh
export FORGEJO_PUSH_WHITELIST=drew
tools/preflight_phase01.sh
tools/duplicate_prs_to_fork.py --count 30
For step 2 there is also a one-liner helper that pins all the fork-mode env vars at once and validates the target repo before exporting anything:
source tools/launch_fork.sh
# Repo target:
# FORGEJO_OWNER, FORGEJO_REPO, FORGEJO_URL, FORGEJO_API_BASE,
# FORGEJO_DEFAULT_BRANCH, FORGEJO_ORG.
# Auto-agents env contract (all 8 required by .opencode/agents/auto-agents.md):
# GIT_USER_NAME, GIT_USER_EMAIL,
# FORGEJO_PAT, FORGEJO_USERNAME, FORGEJO_PASSWORD,
# FORGEJO_REVIEWER_PAT, FORGEJO_REVIEWER_USERNAME, FORGEJO_REVIEWER_PASSWORD.
# Driver-side compat (synthesized from FORGEJO_PAT):
# GITEA_TOKEN — so the deterministic Python tools that still read
# this name authenticate as HAL9000 too. Pre-export
# GITEA_TOKEN before sourcing to override.
# Optional pass-through:
# CA_MAX_PARALLEL_WORKERS.
# The launcher resolves canonical names (FORGEJO_*, GIT_*) from the
# HAL_9000_*/HAL_9001_* aliases in .devcontainer/.env, derives
# FORGEJO_USERNAME / FORGEJO_REVIEWER_USERNAME via /api/v1/user whoami,
# and refuses to export anything if (a) any required value is unresolved
# or (b) the fork target fails a `GET /repos/<owner>/<repo>` validation
# check (must exist, be a fork, grant push to the calling user).
# Override the fork target with:
# FORK_OWNER=... FORK_REPO=... source tools/launch_fork.sh
Always prefer source tools/launch_fork.sh over hand-exporting the
fork-mode vars. Two reasons:
- The LLM orchestrator (
.opencode/agents/auto-agents.md) falls back togit remote get-url originforFORGEJO_OWNER/FORGEJO_REPOwhen its env vars are unset. The canonical clone's origin points at upstream, so a fork-mode launch with stale environment silently targets upstream. - The auto-agents agent will exit immediately if any of its 8
required env vars are missing (
GIT_USER_NAME,GIT_USER_EMAIL,FORGEJO_PAT,FORGEJO_USERNAME,FORGEJO_PASSWORD,FORGEJO_REVIEWER_PAT,FORGEJO_REVIEWER_USERNAME,FORGEJO_REVIEWER_PASSWORD). The launcher surfaces that failure up front instead of 30 minutes into a run.
After sourcing, the canonical LLM-side launcher is
scripts/opencode-builder.sh (which boots an OpenCode server, opens
an auto-agents session, and keeps it nudged forever). The driver
side is python3 tools/merge_drive.py. Both must be running for
end-to-end coverage.
Two-identity convention (production-fidelity)
The pipeline runs on two Forgejo identities — the same two production runs with, per Jeff's confirmed env at 2026-05-05. There is no "operator admin" third identity; everything goes through HAL9000 + HAL9001:
| Identity | Forgejo account | Repo permission | env vars set by launcher | .env keys consumed |
|---|---|---|---|---|
| Primary bot (HAL9000) | HAL9000 |
admin | FORGEJO_PAT, FORGEJO_USERNAME, FORGEJO_PASSWORD, GIT_USER_NAME, GIT_USER_EMAIL, GITEA_TOKEN (synthesized) |
HAL_9000_API_KEY, HAL_9000_FORGEJO_PASSWORD, GIT_USER_NAME, GIT_USER_EMAIL |
| Reviewer bot (HAL9001) | HAL9001 |
write | FORGEJO_REVIEWER_PAT, FORGEJO_REVIEWER_USERNAME, FORGEJO_REVIEWER_PASSWORD |
HAL_9001_API_KEY, HAL_9001_FORGEJO_PASSWORD |
HAL9000 does everything — LLM-worker git activity (commits, comments,
reviews), the deterministic merge driver (tools/merge_drive.py), the
verifier, and every preflight script. HAL9001 has exactly one job:
auto-approving HAL9000's umbrella PRs (Forgejo blocks self-approval, so
this must be a different account from HAL9000).
The launcher synthesizes GITEA_TOKEN=$FORGEJO_PAT so the deterministic
Python tools (which still read GITEA_TOKEN by name) authenticate as
HAL9000. To override with a different identity (e.g. drew's admin PAT
for a one-time fork-setup task), pre-export GITEA_TOKEN before
sourcing the launcher.
The LLM agents use the web password (*_PASSWORD) for two purposes
documented in .opencode/skills/forgejo-api/: basic-auth token CRUD on
/users/{username}/tokens, and cookie-based web login for CI-action
log scraping (REST API does not expose action logs).
FORGEJO_USERNAME / FORGEJO_REVIEWER_USERNAME are auto-derived from
each PAT via /api/v1/user whoami if unset, so the operator only has
to set the PATs and passwords.
Fork-mode prerequisites the launcher does not check (operator must ensure these once, up front):
- HAL9000 must be added as an
admincollaborator on the fork (PUT /repos/<owner>/<repo>/collaborators/HAL9000with{"permission": "admin"}). Production has HAL9000 as admin oncleveragents/cleveragents-core, andaudit_branch_protection.pyplussetup_branch_protection.py --dry-runin the preflight read/branch_protections, which is admin-only. Without admin those steps fail with HTTP 403. - HAL9001 must be added as a
writecollaborator on the fork (same endpoint,"permission": "write"). HAL9001 only auto-approves PRs, which is a write-level operation — it does not need admin. - The fork's
apply_to_adminsbranch protection should betrue(PATCH the branch-protection rule). Without it HAL9000 — which is admin on the fork — would silently bypass required-status-check gates and the merge driver would land merges that haven't passed CI. Withapply_to_admins=trueHAL9000 follows the same rules every other contributor does.
All three prerequisites are documented as one-time setup in
CHANGELOG.md under the prelaunch fork environment fixes entry.
Production launch story (and how to reproduce it against a fork)
There is no production launch config (cron, systemd, Compose, workflow) checked into this repo. The deployed schedules and unit files live on whichever operator host runs the canonical pipeline. What the repo does tell us:
| Component | Production invocation pattern | Trigger |
|---|---|---|
tools/merge_drive.py |
Long-running daemon: python3 tools/merge_drive.py until SIGTERM |
Hinted as a host-level systemd unit named merge-driver (only the systemctl stop merge-driver reference exists in this repo; the unit file does not) |
tools/verify_invariant.py |
Single-shot per invocation: python3 tools/verify_invariant.py [--mode observe|alert] [--dry-run] |
Cron @ 15-min cadence (documented narratively; cron entry is on the operator host) |
tools/flag_stale_prs.py |
Single-shot: python3 tools/flag_stale_prs.py [--days N] |
Cron @ "every few hours" (cadence documented narratively; entry is on the operator host) |
| Reviewer / Implementer dispatchers | Long-running deterministic Python daemons: tools/dispatch_review.py and tools/dispatch_implementer.py (started together by scripts/dispatchers-launcher.sh or the equivalent systemd unit). They invoke the LLM workers (pr-review-worker, implementation-worker) per work item via OpenCode HTTP. The legacy pr-review-supervisor.md / implementation-supervisor.md LLM agents that previously owned this orchestration were decommissioned 2026-05-09 — their files are gone from .opencode/agents/. Merge orchestration is its own deterministic daemon, tools/merge_drive.py. |
Hinted as host-level systemd units; the dispatcher launcher is scripts/dispatchers-launcher.sh and a sample unit ships in contrib/systemd/cleveragents-dispatchers.service. |
auto-agents watchdog |
Single OpenCode session running the watchdog-only auto-agents agent. Reads dispatcher heartbeat files and logs WARNING when either goes stale. Does NOT restart anything (host-level process supervisor's job). |
scripts/opencode-builder.sh (creates/attaches to OpenCode and starts the session). |
To reproduce the production pipeline against the fork, source the launcher once per shell and run the same commands the production host runs, in three separate shells:
# All three shells start with the same preamble. Both the driver and
# the LLM fleet authenticate as HAL9000 (production-fidelity); HAL9001
# is used only for the umbrella-PR auto-approval path inside the
# driver.
source tools/launch_fork.sh
export FORGEJO_PUSH_WHITELIST=drew
# Shell A — driver daemon (deterministic merges + invariant proofs)
python3 tools/merge_drive.py # hold open
# Shell B — verifier on a 15-min cron equivalent
while true; do
python3 tools/verify_invariant.py --mode alert
sleep 900
done
# Shell C — Reviewer + implementer dispatchers (deterministic Python).
# These are the daemons that replaced the legacy LLM supervisors
# (decommissioned 2026-05-09). The launcher reaps each child and
# applies a per-child crash-loop limit.
scripts/dispatchers-launcher.sh # hold open; SIGTERM for clean stop
# Shell C' (optional) — auto-agents watchdog session. The dispatchers
# above own all orchestration; this session only watches their
# heartbeat files and logs WARNINGs when either goes stale. NOT
# required for correct operation — the host-level process supervisor
# (systemd / runit / docker) is the one that actually restarts
# dispatchers; this is just an in-cluster log channel.
scripts/opencode-builder.sh # hold open; Ctrl+C to stop
# Shell D — conflict-resolution driver daemon (deterministic rebase +
# LLM-assisted conflict resolution; release path is also deterministic)
python3 tools/conflict_drive.py # hold open
# Shell E (optional but strongly recommended) — telemetry console.
# Loopback-only by default; consolidates daemon health, cycle
# telemetry, PR label flow, OpenCode sessions, and token/cost into
# a single browser tab. Reads the same per-(owner,repo) cache the
# drivers write to.
python3 .opencode/telemetry/server.py # http://127.0.0.1:8765
Shell E — pipeline telemetry console
/.opencode/telemetry/server.py is a stdlib-only HTTP server that
exposes everything the drivers emit (SQLite merge_cycle /
conflict_drive_cycles / llm_activity, daemon PIDs + heartbeats,
live Forgejo PRs by auto/* label, OpenCode sessions, token+cost
rollup) in a tabbed web UI. Source the same launcher the drivers use
so the console reads the same per-(owner, repo) cache file the
drivers wrote to:
source tools/launch_fork.sh # or canonical-mode env
python3 .opencode/telemetry/server.py # 127.0.0.1:8765
Per-tab polling — inactive tabs don't fetch — server load on a
long-lived browser session stays flat. Default bind is loopback-only;
exposing on a public interface requires putting auth in front via a
reverse proxy. Console never writes to Forgejo, OpenCode, or the
SQLite cache. Full operator notes: .opencode/telemetry/README.md.
Shell D — conflict-resolution driver
tools/conflict_drive.py is the deterministic sibling of merge_drive.py
that owns the auto/needs-conflict-resolution label end-to-end. Each
cycle it picks the oldest open PR carrying that label, runs a
deterministic git rebase origin/master in a fresh clone, and only
delegates to conflict-resolver-worker (a single-shot OpenCode subagent)
when the rebase actually conflicts. The driver verifies the resolution,
force-pushes with --force-with-lease, and removes the label. On
unrecoverable cases (e.g. a delete-vs-rewrite conflict the worker cannot
resolve semantically) it commits the conflict markers, retains the
label, and after three definite failures in 24h escalates to
auto/needs-implementer for a human implementer.
Operator-facing notes:
- Committer attribution. The resolution commit is authored by the
PR author (preserved) but committed as
Forgejo(matchesmerge_driveconvention; HAL9000 is the bot identity for the merge, not the rebase). unresolvableoutcome. When the worker exitsunresolvablethe driver still pushes a branch, but that branch contains the conflict markers committed verbatim. CI will fail;merge_drive.pywill refuse to merge it. The point of this mode is to give a human implementer the exact rebase frontier to fix in one place — not five separate PRs whosegit logno longer matches master's tip.- Approval drift. If a PR loses approval mid-cycle (reviewer dismisses,
author force-pushes),
conflict_drive.pystill resolves the conflict. The merge gate stays atmerge_drive.py, which will refuse the merge until approvals are re-collected. The two drivers are intentionally independent on this axis.
Required TTL constraint
MERGE_DRIVER_CLAIM_TTL_SECONDS must be at least
CONFLICT_DRIVER_WORKER_TIMEOUT_SECONDS + 300. conflict_drive.py
asserts this at startup and aborts (sys.exit(2)) if the constraint is
violated. The 300 s buffer covers the deterministic rebase, the post-worker
verification (git status / git diff --check / git merge-base --is-ancestor), the force-with-lease push, and label cleanup. Without
this buffer, the merge driver could time out a PR's claim while the
conflict driver is still in the verification phase, opening a window for
two drivers to push to the same head.
auto/blocked-by-deps semantics
merge_drive.pr_is_eligible queries /issues/{n}/blocks and applies
auto/blocked-by-deps (orange d93f0b) when the PR has any open
blocking issue. The driver removes the label automatically the moment
all dependencies close — operators do not need to flip the label
manually to unstick a PR. The label exists for visibility (it explains
to the reviewer fleet why a PR is being skipped) and to keep the
release-path scoreboard accurate; the merge driver short-circuits on
the dependency check before consuming any further budget on that PR.
How to run the local conflict-driver integration test
The integration test in
tests/auto_agents/integration/test_conflict_resolution_e2e.py is
opt-in and requires a Forgejo PAT — it is intentionally not wired
into Forgejo CI (the Actions runner has no fork PAT). Run it locally
against your fork:
export CONFLICT_DRIVER_INTEGRATION_TEST=1
export FORGEJO_OWNER=<your-fork-owner> # e.g. drew
export GITEA_TOKEN=<your-fork-PAT> # write access to the fork
pytest -xvs tests/auto_agents/integration/test_conflict_resolution_e2e.py
The test uses tools/inject_synthetic_conflict.py to open a guaranteed
conflict PR, runs python3 tools/conflict_drive.py --once against it,
polls until auto/needs-conflict-resolution clears, and asserts the
head SHA advanced. A finalizer closes the synthetic PR and deletes the
head branch on test exit (success or failure).
If the test process is killed with SIGKILL (or your machine reboots)
before the finalizer fires, clean up the orphaned PR by hand:
gh -R <fork-owner>/cleveragents-core pr close <pr-number> --delete-branch
`scripts/opencode-builder.sh` boots an OpenCode server (or attaches to
one already running on `127.0.0.1:4096`), creates a session with the
`auto-agents` agent, and runs an infinite `continue`-nudge loop that
also dismisses any blocking question prompts the agent raises. As of
2026-05-09 the `auto-agents` agent is a watchdog-only session — it
reads dispatcher heartbeat files and logs WARNINGs; it does NOT
launch or restart anything. The reviewer / implementer dispatchers
themselves run as deterministic Python daemons in Shell C (above).
Merge orchestration is **intentionally absent** from any LLM fleet —
it is performed by the deterministic `merge_drive.py` daemon in
Shell A.
#### Bot-identity considerations for fork-mode
`tools/merge_drive.py` always sets the local-clone git config to
`user.name=Forgejo` / `user.email=forgejo@cleverthis.com` regardless
of which token is used. Combined with `verify_invariant.py`'s
default `MERGE_BOT_EMAILS={"forgejo@cleverthis.com"}`, fork-mode
runs do **not** need an `FORGEJO_MERGE_BOT_EMAILS` override —
driver-merged commits are correctly recognised as bot-merges in
both modes.
The smoke commit on the sentinel branch (committed as
`drew <drew.morris@cleverthis.com>`) is *not* a driver-style merge;
it was a manual no-op push to verify Actions fired. The driver's
own commits will be authored as the Forgejo bot identity.
The reviewer and implementer dispatchers
(`tools/dispatch_review.py`, `tools/dispatch_implementer.py`) read
their `--owner` / `--repo` from `FORGEJO_OWNER` / `FORGEJO_REPO`
(`launch_fork.sh` exports these to point at the fork), so a
fork-mode dispatcher launched in a separate shell sees only PRs in
the fork and never collides with the production-canonical
dispatchers running on the operator host. The legacy
`implementation-supervisor.md` and `pr-review-supervisor.md` agents
that previously hard-coded `cleveragents/cleveragents-core` were
deleted 2026-05-09; fork-mode isolation for them is moot — they no
longer exist. (`pr-merge-supervisor.md` was decommissioned earlier
in the same vein; same conclusion.)
### Auto-* label registry
Provisioned by `tools/setup_auto_labels.py`. Mutate this list in the
script, not by hand:
| Label | Set by | Cleared by | Meaning |
|-------|--------|------------|---------|
| `auto/claimed-merge` | `merge_drive.py` / `claim_pr.ts` | end of cycle / `claim_pr.ts release` / driver TTL sweep | Currently being processed by the merge driver. Swept by `merge_drive.py:sweep_all_expired_claims` every cycle. |
| `auto/claimed-implementer` | `implementation-worker.md` (Tier 1: Startup step 5) via `claim_pr.ts` | `implementation-worker.md` "Release and Exit" / driver TTL sweep | Currently being processed by an implementer worker. Set on entry, cleared on exit; the merge driver's `sweep_all_expired_claims` releases stuck labels after `claim_ttl_seconds`. |
| `auto/claimed-reviewer` | `pr-review-worker.md` (Tier 1: Startup step 3) via `claim_pr.ts` | `pr-review-worker.md` "Release and Exit" / driver TTL sweep | Currently being processed by a reviewer worker. Set on entry, cleared on exit; the merge driver's `sweep_all_expired_claims` releases stuck labels after `claim_ttl_seconds`. |
| `auto/ci-timeout` | `merge_drive.py` (release path) | next merge attempt that fully completes | Last cycle hit `MERGE_DRIVER_CI_TIMEOUT_S`; PR excluded for `MERGE_DRIVER_COOLDOWN_MINUTES`. |
| `auto/restart-throttled` | `merge_drive.py` (Budget exhaustion) | next clean cycle | Train repeatedly lost master-tempo races. Same cooldown as above. |
| `auto/needs-implementer` | `pr-review-worker.md` (Tier 1F) / `conflict_drive.py` (3rd definite failure in 24h) | next implementer commit | ≥ 5 `REQUEST_CHANGES` cycles, or CI failing on a non-trivial pattern, or conflict driver gave up after the 24h definite-failure budget was exhausted. Escalation flag for `tier-dispatcher`. |
| `auto/needs-conflict-resolution` | `merge_drive.py` (rebase failed during merge cycle) | `conflict_drive.py` on successful resolution + force-push | PR is rebase-blocked against current master. Owned by `tools/conflict_drive.py`. |
| `auto/blocked-by-deps` | `merge_drive.py:pr_is_eligible` (via `_pr_has_open_dependencies`) | `merge_drive.py:pr_is_eligible` (auto-cleanup when all blockers close) | One or more open issue dependencies (`/issues/{n}/blocks`). Removed automatically next cycle when the last blocker closes — operators do not need to flip this manually. |
| `auto/stale-inactivity` | `flag_stale_prs.py` | new commit on PR | PR idle ≥ 28 days with unaddressed `REQUEST_CHANGES`. Assigned to `freemo`. |
| `auto/invariant-violation` | `verify_invariant.py` (issue label) | `verify_invariant.py` auto-close, or operator | Audit found a master commit that did not pass CI on the merged SHA. |
| `auto/driver-down` | external monitor | operator after restart | Liveness heartbeat stale > 5 min. |
| `auto/revert` | operator (Path A) | merged | Revert PR opened in response to an `auto/invariant-violation`. |
| `auto/sentinel` | `duplicate_prs_to_fork.py` | operator (delete fork PR) | Sentinel PR duplicated from upstream into a personal fork for pipeline testing. Lives only in the fork. |
---
## Known Users / Contributors
| Login | Role |
|---|---|
| `freemo` | Jeffrey Phillips Freeman — Founder & CTO |
| `brent.edwards` | Contributor |
| `CoreRasurae` | Contributor |
| `hamza.khyari` | Contributor |
| `HAL9000` | AI agent user |
| `hurui200320` | Contributor |
| `aditya` | Contributor |
| `mngrif` | Contributor |
| `eugen.thaci` | Contributor |
---
## Rules
- Read this file at the start of every new chat.
- After every substantive change, add an entry to `tools/CHANGELOG` with a date and a suggested commit message.
- **Always use `tools/count-master-merges.py` to count PR merges or list master activity.** Never hand-roll a PR API scan, never read from the PR API alone, and never rely on `"Merge pull request"` commit message matching. If the script is missing a feature you need, extend the script — do not bypass it.
- **Always use `tools/render-pr-velocity.py` to refresh `pr-velocity.canvas.tsx`.** Do not hand-edit the generated canvas — it will be overwritten on the next render. Hand-written commentary and custom Key-Findings cards belong in `tools/pr-velocity-notes.toml`; new charts or metrics belong in the renderer + template.
- **Always use `tools/render-milestones.py` to refresh `milestone-completion.canvas.tsx`.** Do not hand-edit the generated canvas — the next render will stomp your changes. Hand-written intro / caveats / Key-Findings text and per-milestone description overrides belong in `tools/milestones-notes.toml`; new metrics, classification rules, or sections belong in the renderer + template.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
Rules:
- ALWAYS read graphify-out/GRAPH_REPORT.md before reading any source files, running grep/glob searches, or answering codebase questions. The graph is your primary map of the codebase.
- IF graphify-out/wiki/index.md EXISTS, navigate it instead of reading raw files
- For cross-module "how does X relate to Y" questions, prefer `graphify query "<question>"`, `graphify path "<A>" "<B>"`, or `graphify explain "<concept>"` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).