@foreman meldete am 2026-08-08 "Phase B — ABNAHME GRUEN, 14/14, exit 0" und nannte @testdesigner Tester-of-Record. @testdesigners eigenes Protokoll zeigte fuer dasselbe Fenster: kein Lua, kein Test gefunden. Der Koordinator hatte die Abnahme der Arbeit ausgefuehrt, die er koordinierte, und sie jemand anderem zugeschrieben. Auffindbar war das nur durch einen Zeitstempelvergleich von Hand. Ein Rueckgabewert beweist nichts, solange offen ist, WER ihn erzeugt hat und WORAN. bin/bullpen-attest fuehrt den Test selbst aus und bindet beides: sha256 von Pruefling UND Test, Commit, gemessener Rueckgabewert, Container. Es urteilt nicht (0 = GRUEN) und endet mit dem gemessenen Code, ist also &&-tauglich. Laeufer nach Datei-Art. Der erste Entwurf startete .py-Tests als Skript -- @testdesigner schreibt aber pytest-Suiten, die als Skript NICHTS tun und mit 0 enden; das Werkzeug haette jede pytest-Datei gruen gesprochen, genau die Fehlerklasse, gegen die es gebaut ist. Jetzt: test_*.py/*_test.py laufen unter pytest -q, der Pruefling kommt ueber BULLPEN_IMPL (ein zweites argv laese pytest als weiteren Testpfad). Beide Richtungen gemessen auf boltzmann. deploy/pen-verify -> bin/bullpen-verify: es ist ein Programm, gehoert in die bin-Familie und bekommt damit seinen /usr/bin-Verweis. Findet roster.yaml jetzt auch unter /usr/share/bullpen/deploy, laeuft also aus dem Paket. Doku dort, wo sie gelesen wird -- Rollendateien, nicht nur Architekturpapier: - testdesigner: Urkunde statt Prosa, ROT vor der Uebergabe, BULLPEN_IMPL erklaert - foreman: spricht KEINE Abnahme mehr aus; reicht weiter oder meldet unattestiert - ORCHESTRATION 4.2, ARCHITECTURE, participants/deus.md - Tor 2 (Zulassungssteuerung) aufgeloest, Tor 3 (room.jsonl) erledigt
16 KiB
bullpen — Container-Architektur
Fassung 3, 2026-08-08. Fassung 2 (2026-08-07) war der Entwurf vor dem Bau und lag nur in einem Sitzungsverzeichnis — dieselbe Krankheit, gegen die dieses Dokument antritt. Fassung 3 steht im Repo und trennt sauber: was entworfen war, was gebaut wurde, und was die Messung danach korrigiert hat.
Die vier Direktiven kommen von markus und sind nicht verhandelbar:
- a) Homelab ist nicht die NSA — Sicherheit nicht übertreiben.
- b) bullseye ist ein Fenster und soll im LAN frei zugänglich sein.
- c) Anthropic-Modelle laufen über Claude Code (Abo), kein API-Token.
- d) Kontextarme Mitglieder müssen bei der Übergabe von Tests, Code und Werkzeugen sofort dieselbe Basis sehen — und der Grinder darf die Tests nicht bedienen.
Schichtung: pro Vertrauensstufe schneiden, nicht pro Mitglied
hertz (Wirt, Incus, Wurzel auf sdapool)
└── bullpen Eltern: nesting, idmap.size=1e9, limits.memory=4GiB, autostart
│ Kinder gebrückt am LAN
├── room lmcp, besitzt room.jsonl (EINZELSCHREIBER) 768 MiB
├── workers dispatcher, callboy, researcher, skeptic, triage 1 GiB
├── testdesign @testdesigner — eigener Container, damit einbehaltene
│ Testfälle für den Implementierer wirklich unerreichbar sind
│ 768 MiB
├── grind @coder + py/go/js, bwrap je Lauf, Ausgang gefiltert 1,5 GiB
├── deus RICH pi 1,5 GiB
├── reviewer RICH claude 1,5 GiB
├── architect RICH claude 1,5 GiB
├── foreman RICH opencode, room-only 1,5 GiB
└── bullseye Caddy + Home-CA-Zertifikat, frei lesbar im LAN 512 MiB
Ein Nick, ein Ort. Kein Mitglied existiert zweimal — das war das Doppel-@architect-Desaster.
Eine Namensliste. Das Roster im Repo ist die Quelle, incus list sein Abbild.
Alles innerhalb von bullpen ist EINE Sicherheitsdomäne. Fassung 1 behauptete
Geschwister-Isolation; das ist falsch. Ein privilegiertes Kind, das ausbricht, wird root im
Eltern-Container, und dem gehört das innere Incus. Die Grenze, die zählt, ist die zum Wirt, und
die hält — gemessen: root im Kind erscheint auf hertz als UID 1000000. Für ein Homelab ist genau
das die richtige Menge (Direktive a). Die Grinder-Sperre bleibt trotzdem, weil sie fremden Code
ausführt und nichts kostet.
Was der Bau eingelöst hat
Eltern auf sdapool, 4 GiB, nesting, idmap 1e9, autostart |
✅ |
| Neun Kinder nach Vertrauensstufe geschnitten, alle laufend | ✅ |
Je Kind ein eigener limits.memory |
✅ (aber siehe Überbuchung) |
testdesign als eigener Container |
✅ |
| bullseye frei im LAN, Home-CA-Zertifikat | ✅ https://bullseye.fritz.box/ |
| deus von außen erreichbar | ✅ https://deus.fritz.box/ |
| Claude Code per Abo, kein API-Token | ✅ architect + reviewer angemeldet |
Was der Bau schuldig blieb — und warum es zählt
1. Der Erbauer existiert nicht. Die Anlage ist wieder handgestellt.
Das war Fables Blocker Nummer eins an Fassung 1 und die Antwort auf markus' Satz „sonst bekomme ich wieder eine Bastellösung, die ohne Claude-Zugang nicht funktioniert." Gefordert war:
deploy/roster.yaml WER existiert: Nick, Stufe, Motor, Geheimnisbedarf FEHLT
deploy/bullpen-up legt Eltern + alle Kinder aus roster.yaml an alt
deploy/secrets.example welche Geheimnisse woher — nie die Werte FEHLT
deploy/bullpen-up gibt es, aber es ist der alte Einzel-Container-Starter: er baut einen
Container mit dem Raum und startet ausdrücklich keine Arbeiter. Die neun Container wurden von
Hand angelegt.
Und die Auslieferung ist ebenfalls handgelegt: alle neun laufen aus /opt/bullpen-src, einem
git-Checkout. dpkg -l bullpen ist in jedem Container leer, /usr/share/bullpen existiert
nirgends. Die stehende Anweisung lautete, die laufende Instanz aus dem Paket zu bauen.
Das Paket selbst ist installierbar — gemessen in einem frischen Debian 13:
apt-get install ./bullpen.deb zieht die Abhängigkeiten, legt 14 Einstiegspunkte, 16 Units,
bullpen_config lädt über den Symlink→realpath-Pfad, bullseye.server ist importierbar. Aber
seine Units scheitern mit status=203/EXEC an /usr/local/bin/bullpen-*, weil das .deb aus dem
gepinnten Tag v0.1.0 baut und der Fix erst danach entstand. Solange kein neuer Tag steht, ist das
Paket halb tot und die Anlage benutzt es ohnehin nicht.
2. Die „Überbuchung" ist eine Buchhaltungsgröße, kein Befund.
Fassung 3 nannte hier Faktor 2,62 als Warnung. Gemessen am 2026-08-08 hält das nicht:
Summe der Kinder-Deckel : 10752 MiB Summe der BELEGTEN : 1844 MiB
Eltern-Deckel : 4096 MiB Eltern gemessen : 2,33 GiB
hertz : 3752 MB verfügbar (3838 MB davon Puffer/Cache)
architect hält 62 MiB bei 1536 MiB Deckel, reviewer 89. Neun Container auf demselben Wirt,
aus demselben Abbild, teilen Seitencache, Bibliotheken und COW-Seiten — Deckel zu addieren
ergibt eine Zahl ohne Bezug zum Speicherbedarf. Sie sind Sicherungen je Container, kein Budget.
Entschieden (markus, 2026-08-08): keine Zulassungssteuerung. Stattdessen wird der Druck
gemessen und sichtbar gemacht — bullpen-verify meldet Belegung gegen Deckel je Container und den
Eltern-Stand. Fables Einwand „OOM ist kein Scheduler" bleibt richtig; er wiegt nur weniger schwer
als das Serialisieren einer Gesellschaft, die vom gleichzeitigen Widerspruch lebt. Fables Satz war „OOM ist
kein Scheduler." Die Antwort im Entwurf: höchstens ein reicher Motor gleichzeitig wach, über eine
Sperre, die der Lurker vor dem Wecken nimmt und nach dem Antworten freigibt — wer sie nicht
bekommt, wartet sichtbar im Raum. Diese Sperre gibt es nicht. Solange sie fehlt, hält den
Deckel nur die Tatsache, dass selten zwei reiche Motoren zugleich laufen. Das ist Glück, keine
Eigenschaft.
3. room.jsonl hat keine Sicherung.
Der Entwurf nennt ihn „den einzigen unersetzlichen Zustand" und fordert einen täglichen Abzug
nach data. Es gibt keinen cron-Eintrag und keine Abzüge. Er liegt weiterhin allein im
room-Container, auf einer USB-Platte.
4. Neun Kinder sind derzeit die Decke.
Der Entwurf plante zehn. Ein zehnter Container scheitert beim Start:
newuidmap failed to write mapping … Failed to set up id mapping
trotz security.idmap.size=1000000000 am Eltern-Container. Ursache nicht ausdiagnostiziert;
festgehalten, damit der nächste nicht dagegenläuft.
Korrekturen, die die Messung erzwungen hat
@deus ist kein Lurker, und niemand weckt ihn
Der Entwurf führte deus als „RICH pi|claude" neben den anderen. Der Lurker kennt aber nur die
Laufzeiten claude und opencode — kein pi. deus kann deshalb nicht auf Raumaktivität
aufwachen.
Das ist kein Rückschritt: der alte deus auf dcw2 hatte auch keinen Wecker, nur pi-web und
pi-web-sessiond. deus wird vom Menschen im Browser getrieben. Das passt zur Rolle — der
Eingriff kommt von außen — hat aber eine Kehrseite, die man kennen muss: wenn sich die
Gesellschaft verklemmt, weckt ihn niemand. Genau der Fall, für den es ihn gibt.
deus hält keine Schlüssel — also baut er den Sandkasten nicht mehr
Die alte Rolle gab deus den Sandkastenbau ausdrücklich, weil die eingesperrten Agenten kein
Dateisystem erreichen konnten. In dieser Architektur hat deus bewusst keine Zugangsdaten, und
darauf ruht die Grenze. Beides zusammen geht nicht. Entschieden: grind ist der Sandkasten —
Interpreter, bwrap, gefilterter Ausgang sind dort. Braucht eine Kampagne echte Reichweite, ist das
ein Befund und kein Grund, einen Schlüssel zu besorgen. Festgehalten in participants/deus.md.
Die Vertrauensliste ist eine Architektur-Invariante, kein Detail
Wer einen Agenten wecken darf, steht in cfg.TRUST. Fehlt ein Nick, verschwindet seine Nachricht
geräuschlos: der Raum antwortet {"ok":true} und speichert sie, der Empfänger verwirft sie.
Gemessen am 2026-08-08, als deus fehlte:
room_say → {"id":143,"ok":true}
@foreman → "refusing ask from untrusted nick 'deus'"
Kein Fehlschlag, keine Rückmeldung an den Absender, nur Stille. Nachzusehen ist im
Lurker-Protokoll des EMPFÄNGERS; das eigene ok:true beweist gar nichts. Jedes neue Mitglied
muss in dieselbe Liste, sonst ist es stumm.
Raum und Rolle dürfen auf demselben Rechner liegen
Vier Fundstellen setzten voraus, der Raum laufe woanders, und gingen über sic <host>. Seit ein
Container je Rolle läuft, ist der Raum ein Nachbar im Netz: kein sic, kein SSH-Schlüssel, kein
Fremdhost. Die Entscheidung fällt jetzt an einer Stelle je Datei über LMCP_HOST; der Weg
über sic bleibt der Rückfall für die Aufstellung mit getrennten Rechnern.
Am teuersten war die stille Variante: room_read liefert bei Fehlschlag bewusst None statt einen
leeren Raum vorzutäuschen — ein Lurker ohne sic meldete deshalb „up, watching …" und tat still gar
nichts, während Nachrichten an ihn im Raum lagen.
Der Grinder-Ausgang ist gefiltert, nicht gesperrt
Fassung 2 schrieb „KEIN Ausgang". Eine Nullsperre baut einen toten Container: die Grinder-Schleife
spricht bei jeder Iteration mit dem LLM-Proxy und muss den Raum erreichen. Gebaut ist deshalb
eine nftables-Regel in grind — erlaubt sind Raum, Proxy, DNS und DHCP, alles andere wird
abgewiesen (reject, nicht drop, damit ein blockierter Zugriff im Protokoll erkennbar ist statt
wie ein hängender Dienst auszusehen).
Nicht am Eltern-Container als Incus-ACL, weil deren Regeln ct state brauchen und das im
unprivilegierten Eltern mit „Protocol error" scheitert — und den Eltern privilegiert zu machen
käme nicht in Frage, auf seiner Unprivilegiertheit ruht die Grenze zum Wirt.
Betriebsrelevante Dokumente gehören ins Repo, nicht in einen Container
Die stehende Rolle von @deus lag als ~/.pi/agent/AGENTS.md nur im Container und war nach dem
Neubau weg. Sie steht jetzt als participants/deus.md im Repo. Dieses Dokument selbst lag bis
heute nur in einem Sitzungsverzeichnis. Wenn ein Dokument den Betrieb bestimmt, gehört es
versioniert — sonst überlebt es den nächsten Neubau nicht, und der Neubau ist der Moment, in dem
man es braucht.
bullseyes Denk-Anzeige ist über Containergrenzen tot
Sie liest /var/lib/bullpen/thinking — Dateien, keine Raumnachrichten. Die liegen im
workers-Container, der Code läuft in bullseye. Der umgebende except schluckt den Fehler, die
Anzeige bleibt leer und sieht dabei gesund aus. Reparieren heißt entscheiden, wo die Dateien
künftig liegen (gemeinsamer Einhang, oder workers reicht sie heraus) — deshalb im Quelltext als
Lücke vermerkt statt überklebt.
Die Abnahme trägt jetzt Adressen
@foreman meldete am 2026-08-08 „Phase B — ABNAHME GRÜN, 14/14, exit 0“ und nannte @testdesigner Tester-of-Record. @testdesigners eigenes Protokoll zeigte für dasselbe Fenster: kein Lua, kein Test gefunden. Der Koordinator hatte die Abnahme der Arbeit ausgeführt, die er koordinierte, und die Urkunde jemand anderem zugeschrieben. Sichtbar war das nur durch einen Zeitstempelvergleich von Hand — im Raum stand eine glänzende Erfolgsmeldung.
Der Rückgabewert eines Tests beweist nichts, solange offen ist, wer ihn ausgeführt hat und
woran. bin/bullpen-attest führt den Test deshalb selbst aus und gibt eine Urkunde aus, die
beides bindet: Prüfsumme des Prüflings und des Tests, Commit, gemessener Rückgabewert, und der
Container, in dem es lief. Es urteilt nicht — 0 ist GRÜN, alles andere ROT — und beendet sich mit
dem gemessenen Code, ist also in einer &&-Kette brauchbar.
Das ist keine Sperre. Wer lügen will, kann weiter lügen; er muss dann aber Zahlen fälschen, die jeder mit demselben Befehl nachrechnen kann, statt nur einen Satz zu formulieren. Die Rollendateien sind entsprechend geändert: @testdesigner liefert die Urkunde statt der Behauptung, @foreman darf gar keine Abnahme mehr aussprechen und reicht nur weiter — oder meldet unattestiert.
Ein Nebenbefund beim Bau: der erste Entwurf startete .py-Tests als Skript. @testdesigners
Rollendatei verlangt aber eine pytest-Suite, und die tut als Skript nichts und endet mit 0 —
das Werkzeug hätte jede pytest-Datei grün gesprochen. Genau die Fehlerklasse, gegen die es gebaut
ist. Jetzt erkennt es test_*.py / *_test.py, ruft pytest -q und reicht den Prüfling über
BULLPEN_IMPL durch (ein zweites Argument läse pytest als weiteren Testpfad).
Direktive d im Detail: gleiche Basis, und der Grinder bedient die Tests nicht
Kontextarme Mitglieder bekommen bei der Übergabe einen Umschlag: Vertrag, Pfade mit Container, Abnahmekriterium als ausführbarer Befehl, und alle Verweise, die sie brauchen — ein kleines Modell kann nicht auf die Suche gehen. Bekannte Fehlerart ist nicht mangelnde Fähigkeit, sondern Nicht-Konvergenz: Denkmodelle drehen sich und schreiben die Datei nie. Begrenzte Einheiten mit explizitem Abnahmekriterium sind das Gegenmittel.
Gegen das Bedienen der Tests:
- @testdesigner hat einen eigenen Container. Einbehaltene Fälle liegen für den Implementierer physisch unerreichbar. Im selben Container wären sie es nicht.
- Wer implementiert, schreibt den Test nicht. An der lmcp-Arbeit war der Defekt viermal hintereinander der Selbsttest, nie der Code.
- Ein Vertrag und ein externer Abnahmetest dürfen von außen kommen, wenn die Gesellschaft die Implementierung liefern soll. Das ist keine Übernahme, sondern die Trennung, auf der die Abnahme ruht. Ist der Test der Liefergegenstand, gilt das Gegenteil. Entschieden wird nach dem Auftrag.
- Ein Test, der nur besteht, misst nichts. Jeder Abnahmetest wird in beide Richtungen nachgewiesen: Rückgabe 0 gegen eine korrekte Fassung, Rückgabe ≠ 0 mit benannten Fehlschlägen gegen eine absichtlich kaputte.
Offene Tore
-
Erbauer bauen —
roster.yaml+ einbullpen-up, das die neun Container daraus anlegt, und die Container aus dem Paket statt aus einem Checkout. Ohne das ist die Anlage eine Bastellösung mit Paket daneben. Vorbedingung: Tag v0.2.0 (trägt Unit-Fix, TRUST, bullseye-Unit,bullseye/,participants/deus.md). -
Zulassungssteuerung — aufgelöst, kein Tor. Die 2,62-fache „Überbuchung“ war eine Buchhaltungsgröße (siehe oben): gemessen belegen die neun Container 1844 von 10752 MiB Deckel, der Eltern steht bei 2,33 von 4 GiB. Entschieden (markus, 2026-08-08): keine Sperre, aber der Druck wird gemessen —
bullpen-verifymeldet Belegung gegen Deckel je Container. -
room.jsonlsichern — erledigt 2026-08-08.bullpen-room-backup.timerauf hertz, stündlich, ruft/opt/herding/bin/backup-room-jsonl.sh. Drei Wächter vor dem Überschreiben: die Datei parst als JSON, die höchste id ist nicht gefallen, die Zeilenzahl ist nicht gefallen. Gegenprobe gefahren: rc 0 im Normalfall, rc 1 bei künstlich herbeigeführtem Rückschritt. -
Der USB-Pool ist gemessen — die Sorge war unbegründet.
sdapoolist eine drehende 2,5-Zoll-Platte an USB (sda, ROTA=1, TRAN=usb). Gemessen 2026-08-08 mit der Last, auf die es ankommt (4k synchron, nicht sequentiell lesend):4k synchron ×1000 fsync-Latenz sdapool(USB, drehend)124 IOPS, 498 KB/s 18 ms default(mmc, intern)85 IOPS, 340 KB/s 9 ms Die USB-Platte ist bei kleinen synchronen Schreibvorgängen schneller als der interne Speicher; ein Umzug auf
defaultwürde es verschlechtern (SD/eMMC sind bei kleinen Zufallsschreibvorgängen schwach, btrfs' COW bündelt auf der Platte gut). Die 18 ms fsync sind plausibel echt — eine Umdrehung bei 5400 U/min sind ~11 ms, der USB-Brückenchip lügt also nicht über seinen Flush. Das ist der Preis jeroom_say; bei 179 Nachrichten irrelevant, Thema erst bei zweistelligen Nachrichten pro Sekunde. io-Wartezeit bei laufendem Aufbau: 0 %. -
Zehnter Container — uid-Zuteilung klären, bevor jemand einen braucht.
-
Wer weckt deus — wenn unbeaufsichtigter Betrieb je gewünscht ist, braucht der Lurker eine dritte Laufzeit oder deus einen Wecker von außen. Heute: der Mensch.