Zbudowałem lokalny system RAG kilka miesięcy temu, żeby Claude Code mógł przeszukiwać poprzednie sesje. To, co faktycznie przeszukuje, w większości przypadków, to stos surowych transkryptów. Podsumowanie sesji zostaje napisane, zembedowane i wrzucone do pgvector jako kolejny kawałek tekstu. Działa to dla “czy robiłem coś podobnego wcześniej”, ale to nie jest pamięć. To jest archiwum.

Różnica zaczyna mieć znaczenie, kiedy archiwum robi się duże. Surowy log sprzed trzech miesięcy o sesji debugowania ma gdzieś w środku właściwe słowa, ale są one zakopane w tam i z powrotem, ślepych uliczkach i akapicie, gdzie po prostu myślałem na głos. Semantyczne wyszukiwanie nadal to znajduje, mniej więcej. Po prostu zwraca coś bardziej zaszumionego, niż powinno.

Więc zbudowałem drugi etap: coś, co bierze surowe logi i zamienia je w bazę wiedzy, która na tę nazwę zasługuje.

Kod źródłowy, kompilator, plik wykonywalny

Model mentalny, na który wpadłem, to pipeline kompilatora, i jest wystarczająco dosadny, żeby faktycznie prowadzić kod:

1
2
3
daily/      = kod źródłowy    (surowe logi sesji, jeden plik na dzień)
Gemini      = kompilator      (wyciąga i porządkuje wiedzę)
knowledge/  = plik wykonywalny (ustrukturyzowane artykuły concepts/ i connections/)

daily/ jest append-only. Jeden plik markdown na dzień, sesje układane jedna pod drugą w miarę jak się dzieją. Nikt nie edytuje go ręcznie i nic z niego nie usuwa. To jest kod źródłowy: bałaganiarski, chronologiczny, kompletny.

knowledge/ to to, co nocne zadanie z niego produkuje: krótkie, ustrukturyzowane artykuły z frontmatterem, podzielone na concepts/ dla atomowych faktów i connections/ dla rzeczy przekrojowych, tego typu wniosków, które pojawiają się dopiero, gdy widziałem ten sam problem drugi raz. To jest plik wykonywalny. To nie jest po prostu ekstrakcja źródła. To build artefakt, który ma być odpytywany, a nie czytany od początku do końca.

Traktowanie surowej i skompilowanej pamięci jako dwóch osobnych warstw, zamiast jednej, okazało się być całym sensem tego przedsięwzięcia.


graph LR
    A["Sesja Claude Code"] -->|"hook session-end / pre-compact"| B["flush.py
Gemini Flash podsumowuje"] B -->|"dopisanie"| C["daily/YYYY-MM-DD.md"] C -->|"nocny cron"| D["compile.py
Gemini Flash ekstrahuje"] D -->|"3-7 artykułów"| E["knowledge/concepts/
knowledge/connections/"] C -->|"zapis surowy"| F[("pgvector RAG
wpis diary")] E -->|"zapis skompilowany"| F E -->|"mirror"| G["vault Obsidian"] style D fill:#f3e5f5,stroke:#6a1b9a style F fill:#e3f2fd,stroke:#1565c0 style G fill:#fff3e0,stroke:#e65100

Przechwytywanie na krawędziach

Trudna część to nie nocne zadanie. To zdobycie surowego materiału w pierwszej kolejności, w momentach, kiedy sesja faktycznie się kończy albo jej kontekst zostaje wyrzucony.

Zajmują się tym dwa hooki. hooks/session-end.py odpala się, kiedy sesja kończy się normalnie. hooks/pre-compact.py odpala się tuż przed tym, jak Claude Code kompaktuje okno kontekstu, czyli w jedynym momencie, w którym historia rozmowy naprawdę zaraz zniknie, a nie po prostu leży nieprzeczytana. Oba przekazują dalej do scripts/flush.py, który wysyła ostatnią rozmowę do Gemini Flash do podsumowania i dopisuje wynik do dzisiejszego daily/YYYY-MM-DD.md.

Użycie taniego, szybkiego modelu tutaj jest celowe. To odpala się przy każdym końcu sesji i każdej kompaktacji, czasem kilka razy dziennie. Nie musi być mądre, ma po prostu wyprodukować przyzwoity akapit “oto co się stało” bez kosztu albo opóźnienia, które bym zauważył przy zamykaniu sesji.

Sam dzienny plik ma stały kształt, więc kolejny etap ma coś przewidywalnego do sparsowania:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Daily Log: 2026-06-10

## Sessions

### Session (14:20)

**Context:** Debugowanie niestabilnego runnera CI, który padał sporadycznie na tym samym teście.

**Key Exchanges:**
- Asystent zasugerował sprawdzenie race condition na wspólnym katalogu tymczasowym
- Potwierdzone przez dodanie celowanego logu, race odtworzony lokalnie

**Decisions Made:**
- Przełączono runner na osobny katalog tymczasowy per job zamiast wspólnego

**Lessons Learned:**
- Niestabilność pojawiała się tylko przy równoległym wykonywaniu jobów, nigdy przy seryjnych powtórkach

**Action Items:**
- [ ] Zastosować tę samą poprawkę na pozostałych dwóch runnerach

Append-only, jeden plik na dzień. Jeśli mam trzy sesje w ciągu dnia, układają się jedna pod drugą pod tą samą datą. Nic tu nie ma być ładne. Ma być kompletne.

Decydowanie, co jest warte zachowania

scripts/compile.py odpala się nocą przez cron. Czyta wszystkie dzienne logi, które są nowe albo zmienione od ostatniego uruchomienia, i dla każdego z nich prosi Gemini Flash o wyekstrahowanie artykułów wiedzy: nie podsumowania podsumowania, tylko 3 do 7 odrębnych, ustrukturyzowanych artykułów, zapisywanych do knowledge/concepts/ albo knowledge/connections/.

Zakres 3-7 to część, która faktycznie robi robotę. Jeden dzień może zawierać jeden prawdziwy insight albo sześć. Wymuszenie zakresu zamiast “wyekstrahuj wszystko” albo “wyekstrahuj jedną rzecz” zmusza model do priorytetyzacji. Musi zdecydować, że mimochodem rzucona uwaga o flagi konfiguracyjnej nie zasługuje na artykuł, ale decyzja o zmianie strategii retry już tak. Bez tego ograniczenia, leniwym trybem awarii jest albo zrzucenie całego dnia z powrotem w innej strukturze, co niweczy sens, albo złapanie jednego mglistego tematu i uznanie sprawy za zamkniętą.

Każdy artykuł ma frontmatter i stały układ sekcji:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
---
title: "Diagnosing shared temp directory races in parallel CI jobs"
slug: "ci-shared-tempdir-race"
tags: [ci, debugging, concurrency]
sources: [daily/2026-06-10.md]
updated: "2026-06-10"
---

# Diagnosing shared temp directory races in parallel CI jobs

## Key Points
- Parallel jobs sharing one temp directory can race on file creation and cleanup
- Symptom is intermittent failure that never reproduces in serial reruns
- Per-job temp directories are a low-cost fix that also isolates other side effects

## Details

A CI runner failed roughly one run in ten, always on the same test, never
in serial reruns. The shared symptom across failures was a file that one
job expected to still exist while another job had already cleaned it up.

The fix was giving each job its own temp directory instead of a shared
one under the runner's workspace. This removes an entire class of
cross-job interference, not just this specific race.

## Related Concepts
- [[concepts/ci-runner-isolation]] - broader pattern of per-job resource isolation
- [[concepts/flaky-test-triage]] - how to tell a race from an actual logic bug

## Sources
- daily/2026-06-10.md: switched the runner to a per-job temp directory after reproducing the race locally

To jest przykład skonstruowany, nie prawdziwy, ale ma taki sam kształt, jaki ma każdy realny artykuł: tytuł, slug, tagi, dzienny plik źródłowy, data aktualizacji, krótkie podsumowanie w punktach, kilka akapitów szczegółów, linki do powiązanych artykułów i cytat wracający do konkretnej linii w dziennym logu, z której powstał. Schemat wymaga co najmniej dwóch [[wikilinków]] do innych konceptów na artykuł, co zmusza model do sprawdzenia, co już istnieje, zamiast pisać każdy artykuł tak, jakby był pierwszy. Ta jedna zasada robi więcej niż cokolwiek innego, żeby baza wiedzy nie zamieniła się w stos niepowiązanych notatek, które powtarzają tę samą myśl pod inną nazwą.

concepts/ przechowuje rzeczy atomowe, jeden fakt albo jeden wzorzec na artykuł. connections/ to dla pomysłów, które mają sens dopiero, gdy połączy się dwie osobne sesje, tego typu rzeczy, które zauważasz za piątym razem, kiedy trafiasz na podobny problem, nie za pierwszym.

Gdzie to ostatecznie ląduje, podwójnie

Każdy skompilowany artykuł zostaje zapisany do magazynu pgvector RAG, tego samego, za którym stoi narzędzie rag_save, które zbudowałem do bezpośredniego zapisu dokumentów. To właśnie sprawia, że jest odpytywalny. Zapytaj Claude Code o coś podczas sesji, a może odzyskać skompilowany artykuł, nie surowy transkrypt, z którego powstał.

Ale zostaje też mirrorowany do Obsidian jako zwykły markdown, z wikilinkami i całą resztą. To wydawało się trochę absurdalne do zbudowania. Napisałem cały wpis o usuwaniu mojego vaulta Obsidian, bo utrzymywanie dwóch systemów w synchronizacji było dokładnie tym driftem, którego nie chciałem utrzymywać. A tu jestem, piszę pliki z powrotem do vaulta, celowo.

Różnica polega na tym, do czego służy każda kopia. Baza danych jest do odpytywania: przychodzi zapytanie, porównywany jest embedding, wraca najlepsze dopasowanie. Dobre do “czego nauczyłem się o X”, złe do “pozwól mi poprzeglądać, co wiem ogólnie o CI”. Obsidian jest do przeglądania: klikanie przez wikilinki, zauważanie klastra konceptów formującego się wokół tematu bez konieczności wiedzenia, czego szukać na starcie. Jedno odpowiada na pytanie, które już umiesz sformułować. Drugie jest do pytania, którego jeszcze nie sformułowałeś, a otwieram to na tyle rzadko, że to uczciwa wymiana za nieutrzymywanie tego ręcznie.

Surowy dzienny log też zostaje zapisany bezpośrednio do RAG, jako wpis diary, osobno od skompilowanych artykułów. Więc obie wersje przeżywają: skompilowany artykuł do odpytywania i bałaganiarski oryginał na wypadek, gdyby kompilator coś przeoczył albo źle zinterpretował.

Uzupełnianie luki wstecz

Hooki zaczęły przechwytywać sesje dopiero od momentu wdrożenia. Były tygodnie dziennych logów sprzed tego rozwiązania, siedzące w jakimś improwizowanym formacie notatek, którego wtedy używałem, bez niczego skompilowanego z nich.

scripts/backfill.py istnieje dokładnie po to: jednorazowe uruchomienie na historycznych logach, ta sama ścieżka ekstrakcji co nocne zadanie, tylko wskazana na zaległości zamiast “od wczoraj”. Uruchomiłem go raz, sprawdziłem, że wynik wygląda sensownie, i nie potrzebowałem go od tamtej pory. To ten typ skryptu, który jest wart posiadania tylko wtedy, gdy spodziewasz się, że będzie potrzebny dokładnie raz.

Co bym zrobił inaczej

Jakość ekstrakcji zależy całkowicie od tego, co Gemini Flash uzna za warte 3 do 7 artykułów danego dnia, i nie zawsze ma rację. Niektórymi nocami dzieli jeden pomysł na trzy artykuły, które mówią prawie to samo pod różnymi tytułami. Nie dodałem jeszcze deduplikacji na to, tylko okazjonalne ręczne porządki w knowledge/concepts/, kiedy plik indeksu zaczyna wyglądać powtarzalnie.

Nie ma też pętli zwrotnej. Kompilator nigdy nie uczy się, że napisany przez niego artykuł okazał się błędny albo nieużywany. Po prostu produkuje kolejne artykuły, a te dobre i te przeciętne siedzą obok siebie z równie dobrze wyglądającym frontmatterem. Licznik użycia, zwiększany za każdym razem, kiedy rag_search faktycznie zwraca dany artykuł, prawdopodobnie powiedziałby mi więcej o tym, co jest warte zachowania, niż ponowne przeglądanie indeksu kiedykolwiek to zrobi.

Cron uruchamiający to co noc oznacza też, że nigdy nie widzę ekstrakcji na żywo. Dostaję wynik następnego ranka, co jest w porządku dla sposobu, w jaki z tego korzystam, ale sprawia, że debugowanie złej ekstrakcji jest irytujące: zanim zauważę, że artykuł jest nie tak, dzienny log, z którego powstał, może już mieć tydzień, a ja sam zdążyłem zapomnieć kontekst. Flagi --file i --dry-run w compile.py pomagają, ale tylko jeśli pamiętam, żeby ich użyć.