Files
bullpen/docs/ARCHITECTURE.md
T
marfrit 5eb6291e36 Abnahme als Urkunde: bullpen-attest, und pen-verify wird bullpen-verify
@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
2026-08-08 17:58:38 +02:00

16 KiB
Raw Permalink Blame History

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 opencodekein 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/thinkingDateien, 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

  1. Erbauer bauenroster.yaml + ein bullpen-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).

  2. 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-verify meldet Belegung gegen Deckel je Container.

  3. room.jsonl sichern — erledigt 2026-08-08. bullpen-room-backup.timer auf 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.

  4. Der USB-Pool ist gemessen — die Sorge war unbegründet. sdapool ist 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 default wü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 je room_say; bei 179 Nachrichten irrelevant, Thema erst bei zweistelligen Nachrichten pro Sekunde. io-Wartezeit bei laufendem Aufbau: 0 %.

  5. Zehnter Container — uid-Zuteilung klären, bevor jemand einen braucht.

  6. 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.