bullpen: make @dispatcher + one-shot worker family consumable by orchestrators

The bullpen_worker family (dispatcher/callboy/researcher/librarian/coder) + the triage
supervisor were in cfg.TRUST but absent from the room's PRIVILEGED tables, so their
replies stamped verified=None and an orchestrator's lurker (R4) refused them — @foreman
could consult @dispatcher but never consume the routing (a silent dead-end that stranded
it into a spin). They also addressed replies to a bare nick (no @). Fixed and verified
end-to-end (a @dispatcher reply now lands to=@markus, verified=true):

- lib/bullpen_worker.py: address replies as @<invoker>; read POST_SECRET and attach
  secret= to every room_say (the strict :8080 gate REJECTS a privileged post lacking
  the secret, not merely leaving it unverified). Workers run as root -> secret readable.
- lmcp-tools/bullpen.lua (:8080) + bullpen-room.lua (:8081): add dispatcher/callboy/
  researcher/librarian/coder/triage to PRIVILEGED. Keep the two tables in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
This commit is contained in:
2026-07-24 19:05:40 +02:00
parent a850cc95ae
commit 50009af7d9
3 changed files with 32 additions and 5 deletions
+20 -3
View File
@@ -20,11 +20,27 @@ Reliability (reviewer findings #2/#4/#5):
- #5: the offset is advanced AND persisted per fully-handled message, so a restart
mid-batch resumes exactly, never re-dispatching completed work.
"""
import json, subprocess, sys, time
import json, os, subprocess, sys, time
import bullpen_config as cfg
ROOM_DIR = cfg.STATE_DIR
# R4: the room's :8080 gate REJECTS a PRIVILEGED nick that posts without the shared secret
# (bullpen.lua). The one-shot worker family (dispatcher/callboy/…) is now PRIVILEGED so its
# replies stamp verified:true and orchestrators' lurkers will accept them — which means every
# post MUST carry the secret. Read it once (workers run as root; /etc/bullpen/post-secret is
# root-readable); empty is fine for a non-privileged nick (the gate ignores the arg then).
def _post_secret():
for p in ("/etc/bullpen/post-secret", os.path.expanduser("~/.config/bullpen/post-secret")):
try:
s = open(p).read().strip()
if s:
return s
except OSError:
pass
return ""
POST_SECRET = _post_secret()
def lmcp(tool, **kw):
return subprocess.run(["lmcp-tool", tool] + [f"{k}={v}" for k, v in kw.items()],
capture_output=True, text=True)
@@ -55,6 +71,7 @@ def run(nick, dispatch, online=None, ack="…on it", poll=2.0):
kw = {"from": nick, "body": body, "type": typ}
if to: kw["to"] = to
if in_reply_to: kw["in_reply_to"] = in_reply_to
if POST_SECRET: kw["secret"] = POST_SECRET # R4: privileged nicks must present the secret
return _ok(lmcp("room_say", **kw)) # #2: report whether the post landed
def addressed(m):
@@ -84,14 +101,14 @@ def run(nick, dispatch, online=None, ack="…on it", poll=2.0):
if m.get("from") == nick or m.get("type") in ("ack", "reply", "system") or not addressed(m):
since = mid; persist(since); continue # #5: advance+persist per message
rid, asker = mid, m.get("from", "")
say(ack, to=asker, typ="ack", in_reply_to=rid)
say(ack, to=f"@{asker}", typ="ack", in_reply_to=rid)
try:
body = dispatch(m)
except Exception as e:
body = f"error: {e}"
# #2/#3: only step past this message once its reply actually posts; on a failed
# post leave `since` put so the next loop retries — never a silent drop.
if say(body or "(no result)", to=asker, typ="reply", in_reply_to=rid):
if say(body or "(no result)", to=f"@{asker}", typ="reply", in_reply_to=rid):
since = mid; persist(since)
else:
sys.stderr.write(f"{nick}: reply post FAILED for id {rid}; retrying next loop\n")
+6 -1
View File
@@ -14,7 +14,12 @@ local ROOM_LOG = ROOM_DIR .. "/room.jsonl"
local COUNTER = ROOM_DIR .. "/counter"
local SECRET_FILE = os.getenv("BULLPEN_SECRET_FILE") or "/etc/bullpen/post-secret"
local PRIVILEGED = { markus = true, noether = true, foreman = true, herder = true, py = true, reviewer = true, testdesigner = true, godev = true, jsdev = true }
-- Includes the bullpen_worker one-shot family (dispatcher/callboy/researcher/librarian/coder)
-- and the triage supervisor: they're already in cfg.TRUST and consulted by @foreman, but a
-- consumer's lurker requires verified:true (R4), and only PRIVILEGED nicks get that stamp. Left
-- out, @dispatcher's routing replies were structurally unconsumable — @foreman asked its router
-- and never heard back (the "dispatcher -> foreman, verified=None" dead-end, 2026-07-24).
local PRIVILEGED = { markus = true, noether = true, foreman = true, herder = true, py = true, reviewer = true, testdesigner = true, godev = true, jsdev = true, dispatcher = true, callboy = true, researcher = true, librarian = true, coder = true, triage = true }
local function read_counter()
local f = io.open(COUNTER, "r")
+6 -1
View File
@@ -11,7 +11,12 @@ local ROOM_LOG = ROOM_DIR .. "/room.jsonl"
local COUNTER = ROOM_DIR .. "/counter"
local SECRET_FILE = os.getenv("BULLPEN_SECRET_FILE") or "/etc/bullpen/post-secret"
local PRIVILEGED = { markus = true, noether = true, foreman = true, herder = true, py = true, reviewer = true, testdesigner = true, godev = true, jsdev = true }
-- The bullpen_worker one-shot family (dispatcher/callboy/researcher/librarian/coder) + the triage
-- supervisor: promoted so their replies stamp verified:true and orchestrators' lurkers (R4) accept
-- them — without this, @foreman could consult @dispatcher but never consume the routing (the
-- "verified=None dead-end", 2026-07-24). NOTE: this endpoint (bullpen.lua, :8080) REJECTS a
-- privileged post lacking the secret, so bullpen_worker was taught to send it (POST_SECRET).
local PRIVILEGED = { markus = true, noether = true, foreman = true, herder = true, py = true, reviewer = true, testdesigner = true, godev = true, jsdev = true, dispatcher = true, callboy = true, researcher = true, librarian = true, coder = true, triage = true }
local function read_counter()
local f = io.open(COUNTER, "r")