Fassung 2 entstand am 2026-08-07 als Entwurf VOR dem Bau und lag danach
nur in einem Sitzungsverzeichnis. Dieselbe Krankheit, gegen die das
Dokument antritt: die stehende Rolle von @deus lag genauso nur in einem
Container und war nach dem Neubau weg. Wenn ein Dokument den Betrieb
bestimmt, gehoert es versioniert - sonst ueberlebt es den naechsten
Neubau nicht, und der Neubau ist der Moment, in dem man es braucht.
Fassung 3 trennt sauber, was Fassung 2 vermischte: was ENTWORFEN war,
was GEBAUT wurde, und was die Messung danach KORRIGIERT hat. Alle Zahlen
sind nachgemessen, nicht uebernommen.
WAS DER BAU SCHULDIG BLIEB (neu, und der eigentliche Grund fuer diesen
Commit):
* Der Erbauer existiert nicht. roster.yaml und secrets.example fehlen,
deploy/bullpen-up ist noch der alte Einzel-Container-Starter. Die neun
Container sind von Hand angelegt, und sie laufen aus /opt/bullpen-src,
einem git-Checkout - `dpkg -l bullpen` ist in jedem leer. Die stehende
Anweisung war, die laufende Instanz AUS DEM PAKET zu bauen. Das war
Fables Blocker Nummer eins an Fassung 1 und ist wieder offen.
* Die Speicherdeckel summieren sich auf 10752 MiB in einem Eltern mit
4096 MiB: Ueberbuchung 2,62. Genau der Zustand, den die
Zulassungssteuerung verhindern sollte ("OOM ist kein Scheduler") - und
die Sperre gibt es nicht. Was den Deckel heute haelt, ist Glueck.
* room.jsonl hat keine Sicherung, obwohl das Dokument ihn selbst "den
einzigen unersetzlichen Zustand" nennt.
* Neun Kinder sind die Decke; ein zehnter scheitert an newuidmap trotz
idmap.size=1e9.
KORREKTUREN, die die Messung erzwungen hat:
* @deus ist kein Lurker - der Lurker kennt nur claude und opencode, kein
pi. Kein Rueckschritt (der alte deus hatte auch keinen Wecker), aber es
heisst: wenn sich die Gesellschaft verklemmt, weckt ihn niemand.
* deus haelt keine Schluessel, also baut er den Sandkasten nicht mehr -
`grind` IST der Sandkasten.
* TRUST ist eine Architektur-Invariante. Fehlt ein Nick, verschwindet
seine Nachricht geraeuschlos: ok:true beim Absender, verworfen beim
Empfaenger. Nachzusehen ist beim EMPFAENGER.
* Raum und Rolle duerfen auf demselben Rechner liegen (LMCP_HOST).
* Der Grinder-Ausgang ist GEFILTERT, nicht gesperrt - "kein Ausgang"
baut einen toten Container, die Schleife braucht Proxy und Raum.
* bullseyes Denk-Anzeige ist ueber Containergrenzen tot: sie liest
DATEIEN, die im Nachbarcontainer liegen, und der except schluckt es.
13 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 Speicherdeckel sind einzeln gesetzt und in Summe Zierde.
Summe der Kinder-Deckel : 10752 MiB
Eltern-Deckel : 4096 MiB
Überbuchung : Faktor 2,62
Genau der Zustand, den die Zulassungssteuerung verhindern sollte. 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.
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 — solange sie fehlt, ist der 4-GiB-Deckel bei 2,62-facher Überbuchung eine Zusage, die niemand einhält.
room.jsonlsichern — täglich nach data, in den vorhandenen Backup-Weg.- Der USB-Pool ist weiterhin ungemessen.
sdapoolist eine drehende 2,5-Zoll-Platte an USB; neun Container mit Journalen,room.jsonl-fsyncs und SQLite-Sitzungen erzeugen genau die Last, bei der so etwas einbricht — kleine synchrone Schreibvorgänge. Zu messen mit 4k synchron, nicht sequentiell lesend. - 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.