Meldunek agenta nie jest dowodem: bramka odbioru dla robotników AI
Rozdzielam pracę na kilku agentów kodujących naraz: różne modele, różne narzędzia wiersza poleceń, każdy w swoim drzewie roboczym gita. Ja planuję i odbieram. Przez pierwsze tygodnie odbiór polegał na czytaniu meldunku. To był błąd, który wyszedł bardzo konkretnie: agenci meldowali zapis plików, których nie było, uruchamiali kolejnych agentów, o które nikt nie prosił, i kończyli zadanie zdaniem „wszystkie testy przechodzą”.
Kontrakt zamiast zaufania
Pierwsza zmiana: kryterium przyjęcia podaje zlecający przy starcie, nie robotnik przy końcu. Kontrakt to komenda testu, lista plików, które mają powstać, ewentualnie artefakt z minimalnym rozmiarem. Odbiór jest kodem, który ten kontrakt powtarza. Kod wyjścia i log robotnika nie mają znaczenia, bo pisze je ta sama strona, która ma interes w zielonym wyniku.
Druga zmiana: robotnik pracuje w klatce bwrap z własną przestrzenią PID. Drugie uruchomienie binarki agenta w tej klatce kończy się odmową i wpisem do dziennika naruszeń. To zamknęło problem agentów, które same rekrutowały pomocników.
Sześć dróg, którymi zła praca przechodziła odbiór
Potem zleciłem przegląd adwersarialny samej bramki: „znajdź sposób, żeby nieprawdziwa praca dostała zielone”. Przegląd znalazł sześć dróg. Każda jest prawdziwa i każdą da się wykonać bez złej woli, przez zwykłe „naprawię, żeby test przeszedł”:
- Test z kontraktu biegł w drzewie robotnika, więc mierzył
.venv/bin/pytest, który można było podmienić (plik poza gitem). conftest.pyz hakiem zamieniającym FAIL w PASS.addopts = --collect-onlywpyproject.toml: pytest zbiera testy, nie uruchamia ich, kończy kodem 0.xfaildopisany do testu zamiast naprawy.- Tryb „nowy test ma padać na kodzie bazowym” zaliczał kod 127, czyli
command not found. Jeden test-kanarek, który rzeczywiście padał, legalizował wydmuszki obok. - Kontrakt „ma powstać plik” przyjmował plik z jedną literą.
Do tego klatka montowała system plików z zapisem, więc robotnik mógł dosięgnąć katalogu stanu bramki z kontraktem i werdyktem.
Jak wygląda odbiór teraz
- Najpierw diff, potem test. Odbiór odrzuca zmiany w plikach chronionych, w konfiguracji testów, dowiązania symboliczne i narzędzia zmienione po starcie. Test uruchamia się dopiero, gdy diff przejdzie.
- Świeże drzewo. Test biegnie w nowym worktree z commitu HEAD robotnika, z narzędziami z głównego checkoutu, w klatce tylko do odczytu. Plik nieśledzony nie ma jak wpłynąć na wynik.
- Dowód dodatni. Wynik czytam z raportu JUnit przekazanego przez potok (
/dev/fd/NprzezPYTEST_ADDOPTS), nie z wyrażenia regularnego na stdout. Zwykły plik JUnit odpadł, bo kod testu potrafił go nadpisać watexitjuż po zapisie przez pytest. - Padanie na bazie. Ta sama komenda i ta sama izolacja na kodzie bazowym; liczą się tylko kody 1 i 2, i każdy nowy test musi padać, nie jeden.
- Podpisany werdykt. Werdykt ma podpis HMAC, a klucz jest w klatce zasłonięty przez
/dev/null. Narzędzia raportujące nie ufają plikowi bez podpisu.
Rozważyłem i odrzuciłem dwie prostsze opcje. Biała lista montowań (wszystko tylko do odczytu oprócz worktree) psuje same agenty, bo piszą do katalogu domowego. --noconftest wywraca każdy zestaw z fiksturami. Klatka jest więc listą zakazów, a nie listą pozwoleń, i ta granica jest opisana wprost.
Kryteria przed pomiarem
Mechanizm sprawdziłem na prawdziwym projekcie przez jeden dzień pracy kilku agentów. Kryteria spisałem przed startem, w osobnym pliku, z progami. Pięć kryteriów, w tym: zero zleceń na narzędziach spoza mandatu, zero zleceń bez odbioru, brak obejść, odmowy nie blokują pracy, odrzuconych najwyżej 10%.
Wynik: cztery z pięciu spełnione. Zlecenia bez odbioru spadły z 58 na 76 do zera, zlecenia spoza mandatu z 3 do zera. Piąte kryterium padło: odrzucone 4 z 12 u agentów i 7 z 29 w próbie szerokiej.
Kusiło, żeby uznać 24–33% za „blisko” albo przesunąć próg. Zamiast tego rozebrałem wszystkie 7 odrzuceń. Każde było wadą kontraktu, nie pracy:
- vitest zapisuje pamięć podręczną w
node_modules/.vite, a klatka tylko do odczytu zwracałaEROFS, więc dobry kod dostawał czerwone; - audyt i przegląd z definicji nie zmieniają repozytorium, a odbiór wymagał zmian od bazy, więc każdy audyt kończył się odrzuceniem.
Poprawki: katalogi pamięci podręcznej narzędzi dostają w klatce prywatny tmpfs, zlecenia tylko do odczytu mają osobny tryb z obowiązkowym plikiem wyniku (każda inna zmiana w repo to odrzut), a stan „bez zmian” jest osobnym wynikiem, nie odrzuceniem. Raport z próby mówi „nie sprawdziło się w całości” i tak zostaje. Po poprawkach to inna wersja mechanizmu, którą trzeba zmierzyć od nowa.
Czego to nie łapie
Bramka sprawdza, że kod przechodzi test z kontraktu, i nic więcej. Jeśli kontrakt jest słaby, bramka przepuszcza słabą pracę z pieczątką. Dlatego test kontraktu piszę ja albo inny agent niż wykonawca, a oczekiwane wartości biorę z niezależnego źródła. Autor zawsze napisze test, który jego kod przechodzi.
Druga granica: robotnik, który pracuje bezpośrednio w głównym checkoucie zamiast w worktree, nie ma ochrony głównego katalogu. To świadome ograniczenie, zapisane obok decyzji, a nie odkrycie na produkcji.