Der Anbieter "bosch-dspark" war in orcas opencode-Konfiguration ein Alias auf
den Gateway. Er ist am 2026-08-05 in "hossenfelder-proxy" umbenannt worden,
weil sein Name nach einem Container klang, den es seit dem Vortag nicht mehr
gibt - und weil ein Name, der auf etwas anderes zeigt als er sagt, beim
naechsten Fehler in die falsche Richtung weist.
Damit lief OC_MODEL ins Leere. Geprueft: von orcas Anbietern fuehrt
hossenfelder-proxy das Modell unter dem schlichten Namen "deepseek-v4-flash";
"hossenfelder" fuehrt daneben die praefixierten Katalogeintraege, darunter die
kostenpflichtige Wolkenvariante.
Im selben Zug in orca aufgeraeumt (nicht Teil dieses Commits, nur zur
Nachvollziehbarkeit): die Anbieter litellm, bosch-ornith und bosch-qwen sind
aus beiden opencode-Konfigurationen entfernt. LiteLLM ist stillgelegt, die
beiden bosch-Ports 8888 und 8086 sind gemessen tot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
MODEL und OC_MODEL zeigten auf "deepseek-v4-flash-dspark". Dieser Name kam vom
vLLM auf bosch:8888, das mit --served-model-name ...-dspark lief; der Container
ist am 2026-08-04 abgeschaltet worden. An seiner Stelle steht DS4 auf
bosch:8085, und das meldet sich als "deepseek-v4-flash".
Folge war, dass alle sieben Teilnehmerdienste ins Leere liefen. Der Gateway
antwortete zunaechst mit model_not_found; seit der Kostenregler auf 'free'
steht, kommt stattdessen policy_blocked - was in die Irre fuehrt, weil es
nicht der Preis ist, sondern ein Name, den es nicht gibt.
Der Anbieterteil von OC_MODEL bleibt "bosch-dspark": das ist in orcas
opencode-Konfiguration ein Alias, der auf den Gateway zeigt, nicht auf den
abgeschalteten Container. Nur der Modellname war falsch.
Die Vorkommen in tests/ bleiben unberuehrt - das sind aufgezeichnete
Protokollzeilen frueherer Laeufe, also Belege und keine Konfiguration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
tests/test_no_hardcoded_hosts.py verbietet Fleet-Hostnamen in Laufzeit-Strings und
erlaubt genau einen Ort dafuer: lib/bullpen_config.py, wo alle Vorgaben per get() mit
Umgebungsuebersteuerung liegen. bin/models-json trug 'hossenfelder-proxy' als Literal
und riss den Test - eingeschleppt mit Roundhouse-Karte 3 am selben Tag.
Der Wert ist ein Provider-LABEL, kein Hostname, enthaelt aber einen; die Regel prueft
auf Substring, und das ist keine Ueberempfindlichkeit: ihr Zweck ist, dass hiesige
Namen nicht in Code wandern, der veroeffentlicht wird. Uebersteuerbar bleibt er als
BULLPEN_CATALOG_PROVIDER, jetzt nur an einer Stelle statt an zweien.
room_read dekodierte bei JEDEM Aufruf jede Zeile der room.jsonl. Bei 2224 Zeilen
und 1,9 MB kostete das 156,51 ms pro Aufruf. Sieben Worker-Daemons pollen den Room
(je Poll zwei TCP-Verbindungen, gemessen ~6 Anfragen/s), also lief lmcp auf hertz
dauerhaft mit 85-90 % eines Kerns: 5 Tage 20 h CPU-Zeit in 7 Tagen Laufzeit, auf
einem Pi mit vier Kernen, der nebenbei Fleet-Cron, SSH-Gateway und das Backup traegt.
Die Datei ist append-only und room_say vergibt die ids counter-first (Luecken
moeglich, Rueckschritte nicht - im Bestand geprueft: 2224 Zeilen, streng steigend).
Damit traegt ein Offset-Index mit Sprungsuche. Aufgebaut wird inkrementell: pro
Aufruf werden nur die neu angehaengten Bytes gelesen. Der Normalfall - "gibt es
etwas neuer als meine letzte id?" - dekodiert jetzt gar nichts mehr.
A/B gegen die echte room.jsonl, 96/96 Kombinationen aus since/limit byte-identisch.
Leer-Poll 156,51 ms -> 0,00 ms (Faktor 32138), mit Treffern Faktor 3013. Nach dem
Ausrollen gemessen: lmcp-CPU 85-90 % -> 0 %, Syscalls je 5 s von 15839 auf 331.
Beide .lua-Dateien tragen dieselbe room_read-Fassung (bullpen.lua auf :8080,
bullpen-room.lua auf :8081). Die Duplizierung ist Entwurfsabsicht, also wird sie
beidseitig gepflegt statt heimlich zusammengelegt.
Der Poll-Takt faellt zusaetzlich von 2,0/2,5 s auf 10 s. Nach dem Index kostet ein
Leer-Poll fast nichts mehr, aber sieben Prozesse, die alle zwei Sekunden aufwachen,
bleiben Grundrauschen. Preis: bis zu 10 s laengere Aufgreifzeit je Anfrage.
Uebersteuerbar per BULLPEN_POLL_SECONDS. Die Teilnehmer erben den Wert aus
bullpen_worker, statt ihn ein zweites Mal aus der Umgebung zu lesen.
W31 listed "single-session-foreman — cannot be woken by worker replies" as an open gap.
MEASURED against the room log: 233 of 259 replies addressed to @foreman drew a foreman
post within 120 s, median 8 s. That is reply-driven waking, not busy-polling. The gap as
stated was closed on 2026-07-24 by the `.wakereplies` opt-in, three days before the run
reported it.
What is real is the residue. ORCHESTRATION.md §2 states the invariant — "any orchestrator
MUST wake on replies" — and it was written down only after a reply-deaf lurker deadlocked
a campaign. Nothing checks it. The opt-in is a bare flag file in a per-nick lurker dir,
and deploy/install.sh deliberately does not touch those, so a fresh fleet, a restored
home, or a renamed nick brings the herd up looking healthy and quietly unable to finish a
pipeline: the orchestrator ends its turn correctly and waits forever for an event the
lurker discards.
cfg.ORCHESTRATORS names the nicks that drive pipelines (configurable; "foreman" here) and
bullpen-doctor now flags any of them whose lurker lacks the flag. One `test -e` per
orchestrator, read-only. Verified both directions: silent while the flag is present, and
with it moved aside it reports @foreman REPLY-DEAF.
bullpen-up exists to enable and start its roster, so a unit an operator switched off by
hand came back on the next bring-up — with nothing in the output to say it had. That is
not a bug in bullpen-up; it is doing exactly its job. It does mean an operator's "off"
silently expires, which is the failure class markus has flagged before: re-arming an
inhibit is a decision, not housekeeping.
bullpen-selfimprove.timer was live proof — disabled on request 2026-08-01 while #100 and
#118 ran through the room, and `bullpen-up --dry-run` reported `WOULD … enable+start`.
Mechanism: a file named exactly like the unit in /etc/bullpen/inhibit/ or
~/.config/bullpen/inhibit/, whose CONTENT is the reason. Both paths are read so the same
mechanism works in either install layout, and cfg.inhibit_reason() reads at call time —
lifting an inhibit must not require restarting anything. The reason is mandatory by
construction: an empty file still reports "(no reason recorded)", because an inhibit
nobody can explain is archaeology six weeks later.
Deliberately NOT a tear-down: bullpen-up never stops or disables, so an inhibit that
arrives while the unit runs blocks the re-arm, not the current run. Reporting only
"INHIBIT" there would read as "it is off", so that case says ACTIVE explicitly and tells
the operator to stop it by hand if that is what they meant.
tests/test_bullpen_up_inhibit.py pins all four properties plus non-interference with
ordinary bring-up; with the inhibit check removed, exactly those four fail and the
bring-up test stays green.
A worker reported `model qwen3.6-coding` — the rung it ASKED for. That cannot answer
"which rung solved this task", which on 2026-08-01 turned a question that should have
been a glance at the log into a 100-minute measurement.
lib/bullpen_modeltag.py is the pure function, built through the room: contract by
@testdesigner (17 cases, three rounds), implementation ground by @py, reviewed twice by
@reviewer. Each contract round closed a defect the previous one structurally could not
see:
1. unsatisfiable — one case passed an unprefixed name but demanded the prefixed tier,
contradicting a sibling. Caught by a reference implementation written from the
docstring: 11/12, with exactly one impossible failure.
2. incomplete — a same-model free->paid escalation showed no switch, because names
were compared after stripping the prefix. In a cost-transparency module that is
the case that matters. Caught by @reviewer probing the green implementation.
3. false model of reality — the spec assumed `served` carries a prefix. Measured
against the live gateway: the catalog prefixes every id (246 [$], 15 [free],
4 [local], none bare) but the response echoes the model BARE, so every ordinary
call reported an invented switch to [unknown]. Round 2's fix caused this:
tightening the comparison removed the looseness the wire format relies on.
Resolution: a missing prefix on `served` means no tier information, not a different
tier — inherit the asked tier and compare names only.
Two more @reviewer findings folded in: the contract's `assert "modeltag.py" in
inspect.getsource(modeltag)` checked the wrong file (the harness needs the name in the
TEST source, for _infer_targets) and the model satisfied it by pasting the filename into
the docstring — a contract that rewards gaming; and a formatting-only difference
(`[free]x` vs `[free] x`) rendered as a switch to itself.
On integration: four if/else pairs producing identical strings collapsed, plus one
branch made unreachable by the inherit assignment above it — behaviour unchanged, the
17 cases still pass. The default gateway name is no longer a fleet hostname (it leaked
into every room message); GATEWAY derives from cfg.PROXY.
PY_GRIND_TIERS now carries prefixed ids, since the bare name has no tier to report.
Verified the gateway accepts the prefixed form and answers 200.
1. bin/bullpen-up ROSTER collapsed silently. `{cfg.ROOM_HOST: …, cfg.COORD_HOST: …,
cfg.GRIND_HOST: …}` is a dict literal with CONFIGURABLE keys — a small fleet putting
two roles on one machine (the plausible target of #100) lost the earlier entry
without a word. That is the same silent-no-op the commit claimed to fix, one level
down. Now a list of roles merged per host AND per scope, so a combined
room+coordinator box brings up both its system and its user units. Verified: split
roles unchanged (8 system + 12 user), collapsed onto one host keeps all 20.
Also renamed the local `cfg` in main(), which shadowed the config module.
2. bin/bullpen-selfimprove posted as `from=$BULLPEN_COORD_HOST` — a room NICK confused
with a HOSTNAME. "noether" is a privileged nick that merely happens to equal the
coordinator's hostname here; on any other fleet the run would post as an unknown
nick and be rejected silently, which the R4 comment directly above explains. New
cfg.POST_NICK.
Test gaps @reviewer named, both closed:
* shell entrypoints were entirely outside the AST check — and bullpen-selfimprove is
Bash and was one of the offenders that commit had to fix. Added a grep-based check.
* `fritz.box` was missing from the host list, though it is the strongest fleet marker
there is. Adding it immediately surfaced three more real offenders: a hardcoded
mneme URL in bullpen_participant, another in the lurker, and the legitimate
last-resort fallback in bullpen_mem (exempted, with the reason).
Documented rather than fixed: %h resolves to the service manager's home regardless of
User=, and a stray /root/.local/bin copy would shadow every system unit — both now in
the units-test docstring. cfg.FLEET_HOSTS lost the `pve\d`/`dcw\d` families the regex
had, so a future pve5 needs adding by hand; noted at the definition. `orca` added.
57/57 across both portability suites. bullpen-up --dry-run on noether unchanged;
selfimprove composes a brief with zero origin-fleet mentions under overridden hosts.
These two edits existed only in hertz's working tree since the mneme migration —
install.sh's design says editing the live file IS editing the tracked file, but
they were never committed, so every other host still carried the stale wording
and a git pull on hertz would have refused. Bringing them upstream first makes
hertz's tree a clean discard.
Follows the architecture pass on #100. Three classes of change:
1. sys.path — the ten Python entrypoints used two idioms: seven pinned
/usr/local/lib/bullpen (system layout only), three resolved ../lib from
realpath(__file__) first. The second form is a superset: it works in both
layouts and still falls back to /usr/local/lib. Unified on it. Proven: on
noether, where /usr/local/lib/bullpen does not exist, `bullpen-dispatcher
--roster` now runs straight from the checkout — it could not before.
2. Role hosts from config — deus-seize, bullpen-selfimprove, bullpen-up's
ROSTER keys, and the lurker's opencode host/model now come from
bullpen_config (env > /etc/bullpen/bullpen.conf > origin-fleet default), so
behaviour here is unchanged. bullpen-up in particular was a silent no-op on
any other fleet because its ROSTER was keyed by literal hostnames.
3. Advice the system gives BACK — @dispatcher answered every routing question
with `sic hertz room-ask …` and @callboy reported artifacts as `sic hertz
cat …`. On a foreign fleet that is an instruction to talk to a machine that
does not exist. Now cfg.ROOM_HOST.
tests/test_no_hardcoded_hosts.py enforces (3) by AST, not grep: prose in
docstrings and comments stays (why a thing runs on boltzmann is worth keeping),
but a hostname inside a string the program actually uses fails the test. It
caught three sites I had missed by eye — the dispatcher's proxy default, its
"on <host>" routing regex, and the grinder's timeout message.
The routing regex is the interesting one: those names are real fleet knowledge,
not addresses, so they moved to cfg.FLEET_HOSTS rather than being deleted.
Verified both directions — with the default list "on boltzmann" matches and "on
buildbox" does not; with BULLPEN_FLEET_HOSTS="buildbox chatbox" it is exactly
reversed.
Also verified: DRY_RUN=1 bullpen-selfimprove composes a brief with zero mentions
of the origin fleet under overridden hosts; deus-seize still runs; 52/52 across
both portability suites.
NOT fixed, flagged: deus-seize and bullpen-selfimprove pass the room post-secret
as an lmcp-tool argv value, so it is visible in `ps` on the room host (and in
curl's argv inside lmcp-tool). lmcp-tool has no stdin form for argument values;
closing it means changing lmcp-tool, which is deployed fleet-wide.
Per mneme /ideas id 118 (req markus 2026-07-22): hossenfelder is a
failover/auto-routing proxy, so the model NAME in the request is not
necessarily what served it. Every direct-API caller was parsing
choices[0].message.content and discarding the response's own `model`
field — the room reply echoed the ASK, not the reality.
Applies "asked X, served by Y" (only shown when they differ, so the
common non-failover case stays quiet) to:
- bullpen-grinder: ask_model() captures served_model; save_best/report
thread it through so GREEN/RED replies show it.
- bullpen-coder, bullpen-researcher (incl. the SSE stream path — model
is on every chunk, not just a final non-streaming body), bullpen-callboy,
bullpen-triage: same pattern.
- bullpen-dispatcher: the LLM tie-break path only — the deterministic
keyword-match path never touched a model and stays untagged.
- lib/bullpen_participant.py (architect/skeptic conversants): tag
prepended as "> asked X, served by Y" — matches the "> build · model"
convention opencode-backed workers already show natively, so the room
now has one consistent visual convention for "which model" instead of
two (opencode-native vs none).
- lurker/bullpen-lurker (claude -p workers): no proxy failover in this
path, so just reports the configured --model directly rather than
parsing a JSON field the CLI may or may not carry.
Skipped: @librarian, @doctor (no LLM in either — nothing to report).
Local syntax-checked; dispatcher's --once mode exercised for both the
keyword path (untagged, correct) and roster-fallback path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
triage.service has crash-looped since it was enabled (27k+ restarts, never
ran once) because its ExecStart pointed at the user-layout bin path while
the unit was deployed via install.sh --system (symlinks live in
/usr/local/bin). selfimprove.service had the same latent bug, dormant until
its weekly timer next fired.
bullpen_participant.py's _read_chats() only ever collected type=="chat"
messages, so a direct type=="ask" ping (or any one-shot question) to
@skeptic or @architect was invisible to the turn loop. @architect appeared
to work anyway because it has a second, redundant responder
(architect_lurker, opencode-backed) that @skeptic never got — so @skeptic
was silently unreachable outside an active debate turn.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
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
The society was Python (@py) + Go (@godev) + Lua (@coder) only; a real JS/web need
arrived (the Asteroids HTML task had no home). @jsdev closes the JS-language gap:
- grinder: test_kind recognizes .test.js/.mjs -> js; run_tests runs node --test
--test-reporter=tap in the bwrap jail; _js_scores parses TAP (mirrors _go_scores);
per-kind timeouts + prompt suite name. Verified end-to-end: a failing add() spec
ground to GREEN in one iteration.
- jsdev added to cfg.TRUST + the room PRIVILEGED set (both lua variants).
- systemd/bullpen-grinder-jsdev.service (--serve jsdev).
Node --test grades JS LOGIC; a single-file HTML *game* still needs a jsdom/browser
smoke contract (follow-on). Also: boltzmann is now deploy-from-repo (its grinder was
a stale standalone copy that silently ran old code).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
Rollout-readiness pass: nothing in bin/lurker/lmcp-tools now bakes in the origin
fleet's hostnames, paths, user, or agent roster.
- bullpen_config: new COORD_HOST (default noether), TRUST (the privileged/allowed
nick roster, space-separated -> set), CLAUDE_BIN (PATH-resolved "claude"),
CALLBOY_MODEL. bullpen_config.sh + bullpen.conf.example carry the parallels.
- @doctor: literal "boltzmann"/"noether" -> cfg.GRIND_HOST/cfg.COORD_HOST in every
sh()/vitals()/label/JSON-key; grinds_on_boltzmann->grinds_on_grindhost,
claude_on_noether->claude_on_coordhost. @grinder: the two remaining [SIC,
"boltzmann"] exec literals -> GRIND_HOST.
- TRUST DRYed to one cfg.TRUST across @doctor/@grinder/@lurker. This reconciles a
drift: @doctor trusted `triage` but not `godev`, @grinder the reverse; the shared
default is now their union (both, plus the base 15). @lurker keeps its per-instance
~/<nick>_lurker/.trust override on top.
- @lurker: CLAUDE path -> shutil.which(cfg.CLAUDE_BIN). bullpen.lua: ROOM_DIR +
SECRET_FILE now read BULLPEN_STATE_DIR/BULLPEN_SECRET_FILE (env), default unchanged.
- Dead code from the mneme migration removed: HUB_CONF in callboy/researcher/librarian
+ librarian's now-unused _boltzmann_token().
- tests: defaults cover the new keys; a TRUST env-override test. 318 green.
All defaults = origin-fleet values, so a host with no config still behaves identically
(except the intended TRUST union reconciliation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
The `stash` lmcp tool was retired when mneme replaced the pgvector `memory`
container (2026-07-22); every `lmcp-tool stash ...` call in the agents has
returned "Tool not found" since. Rewire to mneme:
- lib/bullpen_mem.py (new): save() -> POST /save (Bearer, {ns,text}); recall()
-> GET /query (BM25), filtered to a namespace prefix. Both fail soft.
- bullpen_config: MNEME_URL + MNEME_TOKEN_FILE knobs (defaults = origin fleet).
- @callboy stash_remember -> remember (mem.save); @researcher stash -> remember;
@librarian _bullpen_recall -> mem.recall(ns="/bullpen"). news-de + README +
bullpen.conf.example wording updated. No `lmcp-tool stash` calls remain.
- tests/test_bullpen_mem.py: save request shape + Bearer + /query ns-filter (mocked).
Verified: live save/recall round-trip on hertz as root against mneme.fritz.box
(id issued, ns-filtered recall returns it, no cross-ns leak); test entry deleted.
317 tests green. Write token plumbed to hertz:/etc/bullpen/mneme-token (0600 root).
Part of the stash teardown: container already gone, orca's dead stash MCP block
removed, no stash skills/extensions. mneme is the successor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
callboy / coder / researcher + lib/bullpen_worker.py now read PROXY, MODEL and
the state paths (STATE_DIR, ART_DIR, HANDS_DIR, CODER_DIR) from bullpen_config
instead of hardcoded literals. Behavior-neutral: every cfg default equals the
value it replaced, so with no config file the fleet runs identically. 312 tests
green. (callboy keeps its own CALLBOY_MODEL tier; ROOM_HOST/GRIND_HOST in the
coordinator layer remain for a later pass.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
- contrib/oc-rpc: portable stub of the opencode RPC driver (new/say/wait/read/
todo/diff/abort/share/list). Sanitized: default OC_URL -> localhost, honors
OC_URL / BULLPEN_OC_URL. Drives one persistent rich-agent session with
continuity instead of a fresh amnesiac process per poke.
- README "Companions & execution": recommend sic for remote-exec hygiene
(netstring argv over ssh, no quoting hell) with both repo links; document
oc-rpc, mneme, lmcp; note companion hosts resolve via bullpen.conf.
- bullpen_config: add BULLPEN_OC_URL (default http://orca.fritz.box:4096) + test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
First slice of making bullpen deployable to a new fleet. One env-style config
file (resolved: $BULLPEN_CONF -> /etc/bullpen/bullpen.conf -> ~/.config/bullpen/config)
parameterizes host roles, the llm gateway, state paths, and the operator nick.
Built-in defaults equal the current fleet values, so a host with no config file
behaves identically; this commit changes no existing script and cannot alter
live behavior.
- lib/bullpen_config.py python loader (env > file > default) + derived paths
- lib/bullpen_config.sh shell source-able equivalent
- bullpen.conf.example documented template (all defaults shown)
- tests/test_bullpen_config.py defaults=fleet, env>file>default, derivation (4)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
#4 a missing/corrupt .since OR a failed room_read now resumes from the newest id /
backs off — never resets to 0 (which re-ran the ENTIRE history through the model).
room_read/_read_since return None on failure to distinguish it from an empty room.
#2 posts are rc-checked; the offset advances past a message only after its reply
actually lands (3 retries) — a transient lmcp/sic failure retries, never silently drops.
#3 dispatch/run exceptions no longer skip the message (offset is reply-gated, not
advanced-then-maybe-fail).
#5 offset is persisted per fully-handled message — a restart mid-batch resumes exactly,
no duplicate ack / re-run.
#6 lurker reply capped at 6000 chars (long Fable reviews no longer blow room limits).
tests/test_worker_reliability.py locks the #4/#2 core (read-fail != empty; rc checks). 6/6.
Also: bp timeouts for testdesigner/py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
dspark politely yields to human on turn ~2 no matter what the prompt says. Enforce
instead of ask: if it targets human before MIN_ROUNDS(9) agent turns, redirect the
stick to the other participant. Hard stop stays at TURN_CAP(12); 42-tripwire still
ends early if the answer surfaces.
Feeding the last 14 chats flat let a new debate inherit the previous round s turns,
so the model thought it was mid-deep-conversation and yielded to human on turn one.
Scope hist to messages since the last human line = this round only.
Overnight meaning-of-life debate. FORBIDDEN=/42|forty-two/ ends the exchange the
moment a PARTICIPANT reaches for it (scans agent turns since last human, so the
human stating the rule does not self-trip — first attempt did exactly that). Nudge
the personas to keep the stick between themselves and go many rounds instead of
dspark politely yielding to human at turn 3. TURN_CAP 6 -> 10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
bullpen_participant harness: a chatty agent holds the stick when the latest chat is
addressed to it, replies (persona LLM), and hands the floor on. Talking-stick enforced:
parse-fail/unknown/offline -> yield to human; >=TURN_CAP consecutive agent turns -> forced
yield (storm-guard). Two example conversants (architect/skeptic) verified debating a real
design question with clean stick-passing + default-to-human. Wired only when you want the
society to converse; on-demand visit/ask/leave needs none of this.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both @callboy and @librarian duplicated the reactive plumbing (poll/say/read/loop/
addressing/since-offset). Lifted it into lib/bullpen_worker.py (bw.run(nick, dispatch));
a worker is now just a dispatch(msg)->reply function. No behavior change — both verified
live after the swap. Adding a 3rd worker is now ~15 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>