This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/blunix/localServices/agent-codex/README.md
2026-09-10 15:52:54 +02:00

290 lines
15 KiB
Markdown

# agent-codex
XMPP-erreichbarer Codex-Agent für die Plattform, in zwei Rollen: **Agent**
(Dialog, weiterhin read-only für alles außer den zwei seit 01.08.2026
namentlich erlaubten Ausnahmen unten) und **Builder** (kann seit
30.07.2026 tatsächlich Dateien ändern, committen und pushen).
Rollen-Definitionen liegen in `agents/` im Repo-Root (`shared.md`,
`agent.md`, `builder.md`, `AGENTS.md` als Loader) — die hier nicht
duplizieren, dort lesen.
## Architektur
```
XMPP-Nachricht an agent@bot.0ff.space
→ agent-xmpp-listener.service (listener.py, go-sendxmpp --listen)
→ Queue: /var/lib/agent-codex/inbox/*.json
→ agent-codex-worker.service (worker.py, pfadgetriggert)
→ codex exec --sandbox read-only -C agents/ --add-dir <repo> ...
(liest AGENTS.md → shared.md + agent.md; --add-dir macht die
Platform-Repos aus AGENT_READABLE_DIRS sichtbar, aber weiterhin
nur lesend -- gefunden 31.07.2026: vorher sah der Agent nur
agents/ selbst und konnte "Untersuche den lokalen Checkout"
gar nicht erfüllen)
→ Antwort per go-sendxmpp zurück an den Absender
XMPP-Nachricht an builder@bot.0ff.space
→ agent-codex-builder-listener.service (dasselbe listener.py, eigene
Queue: /var/lib/agent-codex/builder-inbox/*.json, eigene, engere
AGENT_ALLOWED_SENDERS)
→ agent-codex-builder.service (builder.py, pfadgetriggert)
→ nach validiertem Job-Snapshot entweder
codex exec --sandbox workspace-write -C agents/ --add-dir <repo> ...
oder claude -p mit unveränderlicher Builder-Settings-Datei
→ Antwort + ggf. Commit/Push, immer nur an den Builder-Owner (lukas)
Handoff vom Agent: worker.py erkennt einen ===BUILDER-HANDOFF===-Block
in der Agent-Antwort, entfernt ihn aus der Nutzer-Antwort und legt den
Inhalt selbst als Job in builder-inbox/ ab (kein XMPP-Umweg) --
`sender` im Job ist immer der Builder-Owner, `requested_by` der
ursprüngliche Absender.
```
Jede Rolle läuft als eigenständiger systemd-Service (Listener +
pfadgetriggerter Oneshot-Worker), gemeinsame Konfiguration über
`commonEnvironment` in `../agent-codex.nix`.
## Persistenter Deploy-Runner
Nach einem erfolgreichen direkten (nicht vom Board gesperrten) Commit schreibt
`builder.py` einen deduplizierten, persistenten Auftrag nach
`builder-deploy-inbox/` und beendet seinen Lauf.
`agent-codex-deploy-runner.service` verarbeitet diesen Auftrag unabhängig vom
Builder-Lebenszyklus und führt ausschließlich `nixos-rebuild switch` für
`blunix` aus. Der Job-State (`building`, `committed`, `deploying`, `deployed`
oder Fehlerstatus) liegt unter `builder-state/`. Ein gespeicherter Commit wird
bei SIGTERM nicht erneut modelliert oder committed; Deployment-Wiederholungen
sind begrenzt. Board-Completion wird für NixOS-Commits ausschließlich vom
Runner genau einmal nach `deployed` oder `deploy_failed` geschrieben.
Normale Board-Builder-Jobs und Board-Repair-Jobs verwenden denselben isolierten
Worktree- und Runtime-Harness. Repair-Jobs ergänzen `pipeline_role="fixer"`,
`agents/fixer.md` und ein strikt validiertes `FIX_RESULT` ohne `TARGET_PHASE`;
sie sind ausschließlich für echte Fehler oder negative Reviews vorgesehen.
Beide Board-Pfade setzen `defer_deploy_until_review`, sodass kein Board-Commit
einen direkten Deploy-Auftrag erzeugt. Ausschließlich der Board-Worker darf
nach einem positiven separaten Review des exakten Commits den bestehenden,
auf `blunix` fest begrenzten Deploy-Runner beauftragen.
## Agent-Schreibfähigkeiten: Kalender und Aufgabenliste (seit 01.08.2026)
Zwei eng begrenzte, direkte Schreibfähigkeiten für den sonst read-only
Agent — kein Freigabe-Umweg über den Builder, beide Endpunkte
tailnet-only:
```
Agent-Antwort enthält ===CALENDAR-EVENT===...===END-CALENDAR-EVENT===
→ worker.py: extract_calendar_event() parst TITLE/START/END/DESCRIPTION
→ run_calendar_event() ruft calendar-add-event auf (reines Python +
curl, läuft NIE innerhalb von Codex' eigener Sandbox -- read-only
hat gar kein network_access-Feld) -- PUT gegen
https://calendar.0ff.space/calendar/public (Radicale auf pinix,
LDAP-Auth als bestehender `calendar`-Account)
Agent-Antwort enthält ===TASK-EDIT===...===END-TASK-EDIT===
→ worker.py: extract_task_edit() liest die Anweisung
→ run_task_edit_codex(): zweiter, eng begrenzter
`codex exec --sandbox workspace-write --add-dir /home/drone/tasks`
(nur dieses eine Verzeichnis, nie AGENT_READABLE_DIRS, kein
network_access) -- editiert index.html/style.css im
Aufgabenlisten-Repo (statisches HTML/CSS, kein Flake)
→ gleicher COMMIT-MESSAGE-Marker-Trick wie beim Builder (siehe unten),
aber ohne dessen Freigabe-Gates: run_task_edit() committet/pusht
direkt, sobald Codex den Block setzt
```
Beide Fähigkeiten leben komplett in `worker.py` (nicht `builder.py`) und
haben keine eigenen State-Verzeichnisse -- kein Lock, kein
Freigabe-Datensatz, keine Sitzungs-Fortführung für den Task-Edit-Lauf
(jeder Lauf ist ein frischer, zustandsloser `codex exec`, die
Repo-Dateien nach jedem Commit sind selbst die Kontinuität). Das
Kalender-Ziel ist bewusst `calendar.0ff.space` (roher CalDAV-Port,
tailnet-only über die interne Traefik-Route), nicht das öffentliche
`cal.0ff.space`-Frontend, das strikt lesend bleibt (siehe
`hosts/pinix/localServices/calendar/web.nix`).
## Codex-Sandbox-Eigenheiten (beide am 30.07.2026 live gefunden)
Zwei nicht offensichtliche, harte Grenzen von Codex' eigener Sandbox,
unabhängig von systemd-Sandboxing (`ProtectSystem`/`ReadWritePaths`):
1. **`.git/` ist unter `--sandbox workspace-write` immer schreibgeschützt**,
auch wenn das Repo Teil von `--add-dir` ist. Codex kann also nie selbst
committen/pushen. Deshalb macht `builder.py` das selbst, außerhalb von
Codex' Sandbox: Codex markiert eine fertige Änderung mit
`===COMMIT-MESSAGE===...===END-COMMIT-MESSAGE===`, das Skript
extrahiert den Block, prüft mit `nix flake check` (nur wo `flake.nix`
existiert) und committet/pusht erst danach selbst.
2. **Netzwerkzugriff ist unter `--sandbox workspace-write` standardmäßig
aus** für Befehle, die Codex selbst ausführt (`git ls-remote`,
`git fetch`, ...) -- unabhängig vom systemd-Netzwerk, das offen ist.
Fix: `-c sandbox_workspace_write.network_access=true` beim
`codex exec`-Aufruf in `builder.py`. Ohne das schlägt z. B. das Prüfen
eines fremden PR-Branchs fehl, obwohl derselbe Befehl außerhalb der
Sandbox (z. B. interaktiv als `drone`) klaglos funktioniert.
Beide Male sah der Fehler zunächst nach einem echten Netzwerk-/
Infrastrukturproblem aus (Codex meldete selbst "Netzwerk-Sockets
gesperrt"), war aber jeweils Codex' eigene Sandbox-Policy. Bei
ähnlichen "kann nicht erreichen"-Meldungen aus einem Codex-Job zuerst
hier nachsehen, bevor Zeit in Netzwerk-Debugging auf Infra-Ebene
gesteckt wird.
## Rollen-Trennung (Sender-Allowlists)
- **Agent**: `AGENT_ALLOWED_SENDERS = "@0ff.space"` -- Domain-Wildcard
(neue Syntax in `listener.py`s `allowed_senders()`: ein Eintrag mit
führendem `@` erlaubt jeden Absender auf dieser Domain). Antwortet
jedem 0ff.space-Nutzer.
- **Builder-Direktdialog**: eigener `AGENT_ALLOWED_SENDERS`-Override nur
auf den Builder-Owner (`lukas@0ff.space`) für
`agent-codex-builder-listener`.
- **Handoff vom Agent**: erreicht den Builder immer, unabhängig davon,
wer beim Agent die Konversation geführt hat (`requested_by` im Job).
Antworten des Builders gehen aber immer an den Owner, nie an
`requested_by`.
## Start-Freigabe (`/annehmen`, `/ablehnen`)
Kommt eine Anfrage aus einem Handoff, dessen `requested_by` nicht der
Owner ist, läuft sie nicht einfach autonom los -- Codex wird noch gar
nicht aufgerufen:
1. `queue_for_start_approval()` legt einen Datensatz in
`AGENT_BUILDER_START_PENDING_DIR` an (Job-ID, Absender, `requested_by`,
Auftragstext und Runtime-Snapshot) und schickt dem Owner eine
Zusammenfassung + Job-ID.
2. `/annehmen <id>` lädt den Datensatz, löscht ihn und führt den Auftrag
erst jetzt aus (`run_and_commit()` -- derselbe Codex-Lauf, der auch
für Direktdialog benutzt wird).
3. `/ablehnen <id>` löscht den Datensatz ersatzlos, ohne dass je ein
Codex-Lauf stattfindet.
Hintergrund (gefunden am 30.07.2026): vorher lief jeder Handoff sofort
und unbeaufsichtigt los. Das fiel auf, als ein `nixos-rebuild switch`
für eine unabhängige Änderung `agent-codex-builder.service` neu
gestartet hat, während gerade ein Handoff (neuer Dex-OIDC-Client)
mitten in der Bearbeitung lief -- systemd hat den Prozess per SIGTERM
gekillt, der Job blieb tot in `builder-processing/` liegen, und im
Arbeitsbaum standen danach unvollständige, uncommittete Änderungen
(Dex-Client-Eintrag ohne das zugehörige `secrets/oidcTrek.age`, das nie
erzeugt wurde). Nichts wurde fälschlich committed -- die Freigabe für
den Commit selbst hätte das ohnehin verhindert --, aber der Owner
erfuhr erst durch manuelles Nachschauen davon. Die Start-Freigabe oben
macht Handoffs sichtbar, *bevor* Ressourcen dafür verbraucht werden;
das eigentliche Kill-Problem behebt der SIGTERM-Handler unten.
## Freigabe-Workflow (`/freigeben`, `/verwerfen`)
Kommt eine fertige Änderung aus einem Handoff, dessen `requested_by`
nicht der Owner ist, committet der Builder auch nach der Start-Freigabe
oben nicht sofort:
1. `queue_for_approval()` stasht die Änderung (`git stash push
--include-untracked -m builder-pending-<job_id>`) und legt einen
Datensatz in `AGENT_BUILDER_PENDING_DIR` an.
2. Der Owner bekommt die Zusammenfassung + Job-ID als Antwort.
3. `/freigeben <id>` popt den Stash, prüft (`nix flake check` wo
zutreffend) und committet/pusht wie gewohnt.
4. `/verwerfen <id>` droppt den Stash ersatzlos.
`/neu` verwirft für den Absender den bisherigen Gesprächsstand --
unabhängig von beiden Freigabe-Mechanismen. Beim Agent (worker.py) heißt
das: die resumte Codex-Sitzung wird verworfen. Beim Builder gibt es seit
`docs/intent/builder-iterationslimit.md` keine resumte Sitzung mehr --
`/neu` löscht dort den gespeicherten Zwischenstand-Text, der sonst
automatisch in den nächsten Auftrag einfließt (siehe „Atomare Sessions
und Iterationslimit" unten).
## Atomare Sessions und Iterationslimit (Builder)
Seit `docs/intent/builder-iterationslimit.md` läuft jeder Builder-Auftrag
als eigenständige, neue Modell-Session. Board-Jobs tragen dafür den beim
Start gewählten Snapshot `codex` oder `claude`; ein Wechsel im Board ändert
keine laufende Pipeline. Beide Launcher übergeben nie eine frühere Session
(anders als der Agent in `worker.py`, der weiterhin Codex-Sitzungen resumt).
Statt einer laufenden Sitzung wird die
zuletzt an den Absender gesendete Antwort in `agent.db`s `context`-Tabelle
(`AGENT_DB_PATH`, getrennt von der `sessions`-Tabelle des Agent) unter
`role="builder"` gespeichert und dem nächsten Auftrag desselben Absenders
als klar markierter Kontext-Block vorangestellt -- `/neu` löscht diesen
Eintrag ersatzlos.
Zusätzlich zum Wall-Clock-Timeout zählen die runtime-spezifischen Streaming-
Schleifen die jeweiligen Codex- beziehungsweise Claude-Ereignisse. Für Codex
zählt `common.run_streaming()` jedes `item.completed`-Event aus Codex'
`--json`-Ausgabe live mit (per Popen + Reader-Thread statt
`subprocess.run(capture_output=True)`, damit während des Laufs gezählt
werden kann) und terminiert den Prozess, sobald
`AGENT_BUILDER_ITERATION_LIMIT` (Default 20) überschritten wird. Live
gegen `codex-cli` verifiziert: auch ein Turn mit mehreren Tool-Aufrufen
bleibt ein einziges `turn.started`/`turn.completed`-Paar -- `item.completed`
ist die feinste beobachtbare Granularität, deshalb zählt hier ein
Ereignis als eine Iteration, unabhängig vom Item-Typ.
Bei Limit-Erreichen baut `builder.build_interim_report()` einen
automatisch generierten Zwischenstandsbericht aus den bis dahin
eingegangenen `agent_message`-Texten (Codex selbst kann nach dem
harten Abbruch keinen eigenen Abschlussbericht mehr liefern). Dieser
Pfad überspringt `extract_commit_message()`/`apply_commit()`/
`queue_for_approval()` vollständig -- kein Commit, kein Push, kein
Reviewer-Lauf -- selbst wenn der eingefangene Teiltext zufällig wie ein
`===COMMIT-MESSAGE===`-Block aussieht.
## Unterbrechung durch Neustart (SIGTERM)
`main()` installiert einen `SIGTERM`-Handler (`_handle_sigterm()`),
der bei jedem Neustart des Service -- z. B. weil ein `nixos-rebuild
switch` `agent-codex-builder.service` neu lädt -- greift, während
gerade ein Job läuft: `requeue_interrupted_job()` verschiebt die
Job-Datei zurück von `builder-processing/` nach `AGENT_BUILDER_QUEUE_DIR`
(der pfadgetriggerte Service greift sie beim nächsten Lauf automatisch
wieder auf) und schickt dem Absender eine Notiz, dass der Auftrag
unterbrochen wurde und ggf. schon halb angewendete Dateiänderungen im
Arbeitsbaum stehen. Das verhindert nicht, dass Codex mitten in einer
Bearbeitung getötet wird (das kann jederzeit wieder passieren -- jeder
Neustart des Service während eines aktiven Jobs killt ihn), macht das
Ergebnis aber sichtbar und automatisch wiederholbar statt eines
stillen, für immer verwaisten Eintrags in `builder-processing/`.
Deploys, die diesen Service berühren, sollten daher idealerweise
prüfen, ob gerade ein Job läuft (`systemctl is-active
agent-codex-builder.service`), bevor sie switchen -- das ist aktuell
noch kein automatisierter Check, nur eine manuelle Vorsichtsmaßnahme.
## Mehrere Schreibverzeichnisse
`AGENT_BUILDER_WRITABLE_DIRS` ist doppelpunkt-getrennt (aktuell
`/home/drone/nixos-config:/home/drone/website:/home/drone/dashboard:/home/drone/launcher:/home/drone/calendar-web`).
Jedes Verzeichnis bekommt sein eigenes `--add-dir` und `ReadWritePaths`;
`apply_commit()` iteriert über alle, die tatsächlich Änderungen haben,
und prüft `nix flake check` nur dort, wo `flake.nix` existiert
(nixos-config ja, die anderen vier nein). `commit_and_push()` pusht
dabei auf den jeweils aktuell ausgecheckten Branch (`git rev-parse
--abbrev-ref HEAD`), nicht hartcodiert auf `dev` — `dashboard` und
`launcher` haben nur `main`, und das ist auch, was deren
Auto-Pull-Webhooks tatsächlich ziehen (siehe
`hosts/blunix/localServices/webhook.nix` bzw.
`hosts/pinix/localServices/webhook.nix`).
## State-Verzeichnisse (`/var/lib/agent-codex/`)
| Pfad | Zweck |
| --- | --- |
| `inbox/`, `processing/`, `failed/` | Agent-Job-Queue |
| `builder-inbox/`, `builder-processing/`, `builder-failed/` | Builder-Job-Queue (Handoffs + Direktdialog) |
| `builder-start-pending/` | Start-Freigabe-Datensätze zu offenen `/annehmen`/`/ablehnen`-Vorgängen (vor jedem Codex-Lauf) |
| `builder-pending/` | Freigabe-Datensätze zu offenen `/freigeben`-Vorgängen (nach getaner Arbeit, vor dem Commit) |
| `sessions/`, `builder-sessions/` | Codex-Sitzungs-IDs pro Absender, getrennt pro Rolle (keine Kollision bei gleichem Absender) |
| `senders/`, `builder-senders/` | Marker, ob der Erstkontakt-Hinweis schon geschickt wurde |
| `codex-home/` | `CODEX_HOME` (Auth, Sitzungs-Rollouts unter `sessions/JJJJ/MM/TT/`) |
## Tests
`test_agent_codex.py` -- lädt `listener.py`/`worker.py`/`builder.py`
direkt (kein Package), reine `unittest`. Stash/Approval-Tests laufen
gegen echte temporäre Git-Repos, nicht nur Mocks. Lokal:
```bash
nix-shell -p python3 --run \
"cd hosts/blunix/localServices/agent-codex && python3 -m unittest test_agent_codex.py -v"
```