Otomasyonum 6 Hafta Boyunca Bana 'Başarılı' Dedi: Sessiz Ölümün Anatomisi
Homelab'imde haftada üç kez çalışan zamanlanmış bir görev var. Pazartesi, çarşamba ve cuma sabah 10:00'da tetikleniyor, işini bitirince Telegram'a "tamamlandı" mesajı atıyor. Aylardır o mesajları görüyordum. Geçen hafta bambaşka bir sebeple sistemin içine girdim ve görevin ürettiği son çıktının 17 Haziran tarihli olduğunu fark ettim.
O gün 1 Ağustos'tu. Yani görev 42 gündür hiçbir şey üretmemiş, bu süre boyunca yaklaşık 18 kez çalışmış ve her seferinde başarılı olduğunu bildirmişti.
Bu yazı o 42 günün anatomisi: nerede kırıldı, neden görünmedi, ve gözlem tarafında neyi eksik bıraktığım için görünmedi.
Görev neydi
Bir LLM sağlayıcısına bağlanıp belirli bir dizinde iş yapan bir otomasyondu: dosya okuyor, dosya yazıyor, bir build komutu çalıştırıp sonucu doğruluyor, sonra commit atıyordu. Yani bir "agentic loop" — model tek başına metin üretmiyor, araçları çağırarak gerçek işi yapıyordu.
Çağrı kabaca şöyleydi:
result = await call_provider_a(
prompt=prompt,
working_directory="/Users/efa/projects/<repo>",
allowed_tools=["Bash", "Read", "Write", "Edit", "Glob", "Grep"],
max_budget_usd=10.0,
timeout=1200,
)
İki buçuk ay boyunca bu düzenek çalıştı ve 13 kez istediğim çıktıyı üretti. Sorun, düzeneğin kendisinde değil, taşınmasında çıktı.
Kırıldığı an: imza değişikliği
Haziran ortasında altyapıda bir sadeleştirme yaptım — kullandığım LLM köprülerini kaldırıp her şeyi tek bir yerel sağlayıcıya taşıdım. Ölü kod temizliği sırasında bu görevin çağrısı da mekanik olarak çevrildi:
# öncesi
result = await call_provider_a(
prompt=prompt,
working_directory="/Users/efa/projects/<repo>",
allowed_tools=["Bash", "Read", "Write", "Edit", "Glob", "Grep"],
max_budget_usd=10.0,
timeout=1200,
)
# sonrası
result = await call_provider_b(
prompt=prompt,
timeout=1200,
)
Fark ettiniz mi? working_directory, allowed_tools ve max_budget_usd yok oldu. Sebebi basit ve tamamen sıradan: yeni fonksiyonun imzasında bu parametreler yoktu. Kalsalardı TypeError alırdım, hemen görürdüm. Silindiler, kod çalıştı, kimse bir şey demedi.
Ama o üç parametre kozmetik değildi. allowed_tools olmayınca modele hiçbir araç tanımlanmıyor; model dosya yazamıyor, komut çalıştıramıyor. working_directory olmayınca zaten bir çalışma dizini kavramı kalmıyor. Geriye kalan şey şu: modele uzun bir talimat metni gönderiliyor, model o metne karşılık bir sohbet cevabı üretiyor, cevap bir değişkene atanıyor ve kimse ona bakmıyor.
Fonksiyon istisna atmıyor. Dolayısıyla:
result = await call_provider_b(prompt=prompt, timeout=1200)
# ... istisna yok, demek ki başarılı
return {"status": "sent", "duration": duration}
Başarı ölçütüm "hata almadım"dı. Çıktının var olup olmadığını hiçbir yerde kontrol etmiyordum.
Son koşunun kaydına baktığımda tabloyu net gördüm: status: success, süre 508 saniye. Model sekiz buçuk dakika boyunca düşünüp yazmış, ortaya bir dosya çıkmamış, sistem bunu başarı saymış.
Nasıl fark ettim: iki tarihi yan yana koymak
Arızayı bulmam beş dakika sürdü ve tek bir sorudan çıktı: "bu görevin ürettiği son şey ne zaman üretilmiş?"
Görevin çıktısı versiyon kontrolüne giden dosyalar olduğu için tarih elimin altındaydı:
git log -1 --format="%h %ad %s" --date=short -- <cikti_dizini>/
# ead760c 2026-06-17 ...
Sonra aynı soruyu görevin kendisine sordum — koşu kaydı Redis'te duruyordu:
redis-cli GET "son_kosu:<gorev_adi>"
# {"status": "success", "timestamp": 1785481737.88, "duration": 508.18}
Zaman damgasını çevirince 31 Temmuz 10:08 çıktı. Yani sistem "dün başarıyla koştum" diyor, ürettiği son dosya ise altı hafta öncesine ait. İki tarih arasındaki bu boşluk, aramam gereken tek ipucuydu.
Üçüncü adım, o boşluğun başladığı yerde ne olduğuna bakmaktı:
git log --format="%h %ad %s" --date=short -S "call_provider_a" -- <gorev_dosyasi>
# f537f7c 2026-06-19 refactor(llm): köprü ölü kod temizliği — tam geçiş
Son çıktı 17 Haziran, sağlayıcı göçü 19 Haziran. İki gün. Şüpheli bulunmuştu; commit'in diff'ine bakmak yeterliydi.
Buradaki genel yöntem şu: bir otomasyonun sağlıklı olup olmadığını anlamak için "koştu mu" diye sormayın, "en son ne zaman ürün verdi" diye sorun. Sonra o tarihi deploy/refactor geçmişinizle çakıştırın. Sessiz ölümler neredeyse her zaman bir değişikliğin hemen ardından başlar; aradaki mesafe genelde birkaç gündür.
Bu arada faturayı da hesaplayalım: 42 günde ~18 koşu, koşu başına ortalama birkaç yüz saniye. Kabaca iki buçuk saatlik model hesaplaması, sıfır çıktı için harcanmış. Yerel modelle çalıştığım için parasal bedeli yoktu; bulut sağlayıcısıyla çalışsaydım bu, farkında olmadan ödediğim bir fatura kalemi olacaktı.
Neden 42 gün görünmedi
Üç ayrı savunma katmanım vardı ve üçü de bu arıza tipine kör.
1. Bildirim, işin kanıtı değil. Telegram mesajı "görev koştu" diyordu, "görev üretti" demiyordu. İkisi arasındaki farkı ancak çıktıya bakarak anlarsınız ve mesaj çıktıdan haber vermiyordu.
2. Sayaç metrikleri koşmayı sayar, koşmamayı göstermez. Elimde report_runs_total{status="sent"} gibi bir sayaç vardı ve düzgün artıyordu. Sayaç artıyor olması sistemin çalıştığının kanıtı gibi görünür — oysa burada tam tersini kanıtlıyordu: boşa dönen bir döngünün sayacı da artar. Bir işin yapılmamış olduğunu sayaçla ölçemezsiniz; ölçmek istediğiniz şey yokluk, sayaç ise varlık sayar.
3. Testim yeşildi ve hiçbir şey doğrulamıyordu. Görev için yazdığım birim testi şuna benziyordu:
mock_call.return_value = {"result": "...", "duration": 145.2}
result = await scheduled_job()
assert result["status"] == "sent"
prompt = mock_call.call_args.kwargs["prompt"]
assert "git push" in prompt # prompt metninde geçiyor mu?
assert "build" in prompt
Test, prompt metninin içinde doğru kelimelerin geçtiğini doğruluyordu. Modelin o kelimelerle bir şey yapıp yapmadığını değil. Sağlayıcı değişip araçlar düştüğünde bu test kılını kıpırdatmadı — çünkü prompt aynen duruyordu. Yeşil test, ölü servis.
Kardeş hata: durumu tersine çeviren tek satır
Aynı gün, aynı zamanlayıcıda ikinci bir hata buldum ve bu daha sinsiydi.
Zamanlayıcım görevlerin dönüş değerini şöyle okuyordu:
result = await func()
reported = (result or {}).get("status")
final_status = "error" if reported == "error" else "success"
Sözleşme şuydu: her görev bir sözlük döndürür. Ama zamanlayıcıya bağladığım görevlerden biri rapor değil, bir yedekleme işiydi ve yedeklenen kayıt sayısını, yani bir int döndürüyordu.
Sonuç şu: yedekleme 0 kayıt işlediğinde (0 or {}) boş sözlüğe düşüyor, .get() çalışıyor, durum "success" oluyor. Yedekleme gerçekten iş yaptığında — diyelim 3 sohbet yedeklendi — (3 or {}) üçe eşit oluyor, int üzerinde .get() çağrılıyor, AttributeError fırlıyor, dıştaki except yakalıyor ve durum "error" yazılıyor.
Yani gösterge tam tersine dönmüştü: iş yapan koşu hata, boş koşu başarı görünüyordu. Aylardır öyleymiş.
Düzeltmesi küçük, ama asıl kıymetli olan şey artık test edilebilir bir fonksiyon olması:
def _normalize_result(raw) -> tuple[dict, str]:
"""Zamanlanmış görev dönüşünü (sonuç, durum) çiftine çevir.
Zamanlayıcı rapor olmayan görevleri de taşıyor; bunlar sözlük değil
sayı döndürebiliyor. Dict varsayımı, sayı > 0 olduğunda durumu
TERSİNE çeviriyordu.
"""
result = raw if isinstance(raw, dict) else {"result": raw}
return result, "error" if result.get("status") == "error" else "success"
Dört satırlık bir düzeltme için dört test yazdım: sözlük-başarı, sözlük-hata, int dönüşü, None dönüşü.
Vazgeçtiğim şey: otomasyonu tamir etmedim
İlk refleksim düzeneği yeniden kurmaktı — araçları geri tanımla, çalışma dizinini geçir, eski davranışa dön. Yapmadım ve bence yazının en önemli kısmı bu.
Otomasyon bir konteynerin içinde koşuyordu. Konteynerde ne o repo mount'lu, ne npm var, ne de git kimliği. Eski düzenek çalışıyordu çünkü çağrılan köprü host üzerinde koşuyordu ve gerçek dosya sistemine erişiyordu. Yani "aynısını geri getirmek" demek, konteynere repo mount'lamak, paket yöneticisi kurmak ve içeriye git kimliği taşımak demekti. Küçük bir işi ayakta tutmak için üç ayrı yetki yüzeyi açmak.
Görevi kaldırdım. Ürettiği işi elle yapıyorum; otomatikleştireceksem host tarafında, repoya zaten erişimi olan bir zamanlanmış işle kuracağım. Kaldırırken de kaldırma gerekçesini kodun içine yorum olarak yazdım — altı ay sonra "burada niye bir boşluk var" diye soran kişi ben olacağım.
Bir şeyi tamir etmemek de bir karardır; ama sessizce silmek yerine nedenini bırakırsanız karar olur, aksi halde bilgi kaybı olur.
Kalıcı çözüm: yokluğu ölçmek
Asıl mesele tek bir görev değildi. Sistemde bu şekilde sessizce ölebilecek onlarca zamanlanmış iş var. Sayaçlar bunu göstermediğine göre, son koşu durumunu ölçmem gerekiyordu.
Zaten her koşunun sonucunu Redis'e yazıyordum (son_kosu:<gorev> benzeri bir anahtar altında: zaman damgası, durum, süre). Bu kayıt aylardır oradaydı ve hiçbir yerde okunmuyordu. Onu Prometheus'a taşımak, /metrics ucunda birkaç gauge doldurmaktan ibaretti:
async def refresh_scheduled_task_metrics() -> int:
now = time.time()
for job_name, times in SCHEDULE:
# Beklenen aralık takvimden TÜRETİLİR — eşik elle yazılmaz
EXPECTED_INTERVAL.labels(job=job_name).set(expected_interval_seconds(times))
raw = await redis.get(f"{LAST_RUN_PREFIX}{job_name}")
if not raw:
continue
last = json.loads(raw)
ts = float(last.get("timestamp") or 0)
if ts:
LAST_RUN.labels(job=job_name).set(ts)
LAG.labels(job=job_name).set(max(0.0, now - ts))
LAST_STATUS.labels(job=job_name).set(1 if last.get("status") == "success" else 0)
if (d := last.get("duration")) is not None:
LAST_DURATION.labels(job=job_name).set(float(d))
Buradaki tek ilginç karar expected_interval_seconds: eşiği elle yazmıyorum, görevin kendi takviminden türetip metrik olarak dışarı veriyorum. Günlük bir görev için 86400, günde iki koşan için 43200, haftada üç koşan için 604800/3. Böylece takvimi değiştirdiğimde alarm kuralı bayatlamıyor:
- alert: ScheduledTaskNotRunning
expr: |
scheduler_lag_seconds
> on(job) report_expected_interval_seconds * 1.5
for: 15m
annotations:
summary: "{{ $labels.job }} beklenen aralığın 1.5 katıdır koşmadı"
- alert: ScheduledTaskFailed
expr: report_last_status == 0
for: 10m
# Süre anomalisi — sabit eşik yok, görevin KENDİ geçmişine göre
- alert: ScheduledTaskSlow
expr: |
report_last_duration_seconds > 60
and report_last_duration_seconds
> 3 * avg_over_time(report_last_duration_seconds[7d])
for: 5m
Üçüncü kural doğrudan bu yazının konusu olan vakayı hedefliyor. 508 saniyelik koşu bir hata değildi, "başarılı"ydı — ama o görevin normal süresinin kat kat üstündeydi. İş yapmayan bir LLM döngüsü genelde uzun sürer, çünkü model düşünmeye devam eder, sadece ortaya bir şey çıkmaz. Sabit eşik yerine görevin kendi yedi günlük ortalamasının üç katını kullanıyorum; böylece hangi görevin ne kadar sürdüğünü tek tek bilmem gerekmiyor.
Kuralları devreye aldığım gün ilki hemen ateşlendi ve yukarıda anlattığım ters dönmüş yedekleme durumunu gösterdi. Kurduğunuz alarmın ilk gün gerçek bir şey yakalaması iyi bir işarettir — ama aynı zamanda o şeyin ne kadar zamandır orada durduğunu düşündürür.
Notlar
- Bir çağrıyı başka bir hedefe taşırken imzaları karşılaştırın. Hedefte karşılığı olmayan parametre sessizce silinen bir yetenektir, kozmetik bir fark değil. Benim vakamda üç parametre düştü ve otomasyonun tamamı buharlaştı.
- "İstisna almadım" başarı ölçütü değildir. İş yaptığını iddia eden her görev, çıktısının varlığını doğrulamalı: dosya oluştu mu, commit atıldı mı, satır sayısı sıfırdan büyük mü.
- Testler çıktıya assert etmeli, girdiye değil. Prompt metninde doğru kelimelerin geçtiğini doğrulayan test, sağlayıcı değiştiğinde hiçbir şey söylemez.
- Sayaç varlığı sayar; yokluğu ölçmek için son-koşu zaman damgası gerekir. "Kaç kez koştu" ile "en son ne zaman koştu" bambaşka iki sorudur ve ikincisi olmadan sessiz ölüm görünmez.
- Alarm eşiğini elle yazmayın, konfigürasyondan türetin. Beklenen aralığı takvimden üretip metrik olarak yayınlamak, takvim değiştiğinde kuralın kendiliğinden güncel kalmasını sağlıyor.
- Bir şeyi tamir etmemek de karardır — ama gerekçesini kodda bırakın. Boş bir yer, gelecekte sizi yanıltır.