womr-kanban-governor
Keeps unranked working-lane kanban cards ranked, in-process, on the hermes dispatcher's own tick.
The problem it replaces
Ready-dispatch orders by priority DESC, created_at ASC. specify and
decompose both mint working-lane cards without a priority, so cards arrive at
0, the ORDER BY degenerates to oldest-first FIFO, and the drain hands out
whatever is oldest.
The predecessor was an external script on a systemd timer that shelled out one subprocess per card (~3.3s each, including a first attempt at a rail verb that no longer exists). At ~2450 unranked cards that is ~2.2 hours of subprocess churn against a 2-minute timer and a 30-minute unit timeout — so laps stopped completing at all, and the board went ungoverned while intake continued.
This does the same work with direct DB writes inside a hook hermes already fires. No subprocess, no timer, no external scheduler that can fall behind.
Seam
on_kanban_dispatch_tick fires once per dispatcher tick, strictly after
_dispatch_tick_lock is released (kanban_db.py:9858/9886 → :349), so a slow
subscriber cannot extend the single-writer critical section. The hook is
observer-only: the return value is ignored and this plugin never raises into the
dispatcher.
Guards
| Guard | Why it is load-bearing |
|---|---|
Only priority == 0 rows are scored | an operator's hand-set number is never a candidate |
Computed scores clamped < HAND_SET_FLOOR (90) | structure can never outvote human judgement |
UPDATE ... AND priority = 0 CAS on write | a hand-set landing mid-tick still wins |
max_writes bound (default 200) | the shared tick can never be spent without limit |
| Interval throttle (default 120s) | scoring cost is decoupled from tick frequency |
connect_closing, never connect | long-lived gateway would otherwise leak FDs (#33159) |
PRAGMA busy_timeout pinned (8s) | hook is synchronous on the tick path; an unbounded wait stalls dispatch itself |
sqlite3.OperationalError yields the tick | a busy board is normal, not an error — never contend with the live writer |
| All exceptions swallowed + logged | a governor bug must not take down dispatch |
dry_run ticks return immediately | never writes during a dry-run dispatch |
Scoring
Structural only — no judgement about what a card is about:
leverage= open transitive descendants (what finishing this card releases)free= no OPEN parent (nothing upstream blocks it)
Free cards score 60 + min(leverage, 29); gated cards 30 + min(leverage, 19);
recognised test fixtures sink to 1. Bands do not overlap, so a free card always
outranks a gated one. Leverage traversal is iterative with a seen-set: a cycle in
the link graph cannot hang the tick.
Activation (operator-gated)
Installed disabled. Nothing loads until it is explicitly enabled:
hermes plugins list # shows: disabled
hermes plugins enable womr-kanban-governor
Takes effect for new dispatcher processes; the gateway must be restarted to pick it up (a separate, explicitly gated action).
Rollback
hermes plugins disable womr-kanban-governor
Emergency kill switch without touching config — the plugin returns immediately on every tick:
WOMR_KANBAN_GOVERNOR_DISABLE=1
Tuning
| Env | Default | Meaning |
|---|---|---|
WOMR_KANBAN_GOVERNOR_DISABLE | unset | any non-empty, non-0/false value disables |
WOMR_KANBAN_GOVERNOR_MAX_WRITES | 200 | cards ranked per run |
WOMR_KANBAN_GOVERNOR_INTERVAL_SECONDS | 120 | minimum seconds between runs |
WOMR_KANBAN_GOVERNOR_LOCK_BUDGET_MS | 8000 | fail-fast SQLite busy budget |
Test
python3 test/test_governor.py
12 tests, pure-python, no hermes runtime and no DB — the scoring contract is provable without a board. Validate the wiring against the real loader with:
hermes plugins doctor .