290 lines
15 KiB
Markdown
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"
|
|
```
|