Pada arsitektur self-learning AI agents, worker harvester bertugas mengekstraksi instruksi, skema alat, atau kapabilitas baru ke dalam file katalog terpusat, umumnya bernama AGENTS.md. Masalah muncul ketika beberapa worker berjalan paralel dan memutasi file Markdown secara langsung. Pembacaan file oleh worker lain menghasilkan crash mendadak: Unexpected EOF atau ScannerError: mapping values are not allowed here saat mem-parsing YAML frontmatter.

Masalah ini disebabkan oleh read-write race condition. Artikel ini membedah langkah investigasi I/O di tingkat kernel Linux dan menyajikan solusi definitif menggunakan advisory locking (flock) serta POSIX atomic rename.

Gejala dan Log Kerusakan

Saat beban harvester meningkat, worker yang bertugas memuat dependensi skill dari AGENTS.md melempar exception saat parsing metadata:

yaml.scanner.ScannerError: while scanning a block scalar
  in "AGENTS.md", line 42, column 5
expected <block end>, but found '<stream end>'
yaml.parser.ParserError: Unexpected EOF while parsing frontmatter

Pemeriksaan manual pada file menunjukkan struktur Markdown valid setelah crash terjadi. Ini mengindikasikan file sempat berada dalam status tidak lengkap (partially written) tepat pada saat proses consumer melakukan eksekusi read().

Investigasi: Tracing I/O dengan strace dan lsof

Langkah awal diagnosis adalah memverifikasi apakah ada multiple file descriptor aktif yang menulis ke file target secara bersamaan.

1. Verifikasi File Descriptor dengan lsof

Jalankan lsof terhadap path AGENTS.md selama siklus kerja harvester berlangsung:

$ lsof /var/app/data/AGENTS.md
COMMAND     PID   USER   FD   TYPE DEVICE SIZE/OFF    NODE NAME
harvester-1 4102 deploy    3w   REG    8,1    14205 1845201 /var/app/data/AGENTS.md
harvester-2 4108 deploy    3w   REG    8,1    14205 1845201 /var/app/data/AGENTS.md
agent-core  4115 deploy    4r   REG    8,1     8192 1845201 /var/app/data/AGENTS.md

Output menunjukkan PID 4102 dan 4108 membuka file dalam mode write (3w), sementara PID 4115 sedang membaca (4r) dengan offset pembacaan parsial (8192 byte dari total 14205 byte).

2. Trace Syscall dengan strace

Gunakan strace untuk melihat urutan system call pada proses yang crash:

$ strace -f -p 4115 -e trace=openat,read,write,close
[pid 4115] openat(AT_FDCWD, "/var/app/data/AGENTS.md", O_RDONLY) = 4
[pid 4102] openat(AT_FDCWD, "/var/app/data/AGENTS.md", O_WRONLY|O_CREAT|O_TRUNC) = 3
[pid 4102] write(3, "---\nname: code_search\n", 23) = 23
[pid 4115] read(4, "---\nname: code_search\n", 8192) = 23
[pid 4115] read(4, "", 8169) = 0
[pid 4115] close(4) = 0
[pid 4102] write(3, "description: semantic search\n---\n...", 4096) = 4096

Urutan kejadian di atas mengungkap akar masalah:

  1. Worker pembaca (PID 4115) membuka file untuk diproses.
  2. Worker penulis (PID 4102) mengeksekusi openat dengan flag O_TRUNC, memotong panjang file menjadi 0 byte secara seketika.
  3. PID 4115 membaca buffer yang baru terisi sebagian (23 byte). Pemanggilan read() kedua mengembalikan 0 (EOF).
  4. Parser YAML/Markdown menerima stream terpotong dan memicu crash Unexpected EOF.

Akar Masalah: Tidak Ada Isolasi Mutasi

Kode legacy menggunakan mode penulisan langsung (open(path, 'w') atau open(path, 'a')). Masalah teknis pada pendekatan ini:

  • Non-atomic Truncation: Flag O_TRUNC mengosongkan file sebelum konten baru selesai ditulis ke storage disk.
  • Interleaved Writes: Ketika dua worker menulis secara bersamaan via mode append, blok I/O yang melebihi buffer page OS dapat terfragmentasi dan saling menimpa (lost updates).
  • Unprotected Read Phase: Reader tidak mengetahui apakah proses flush I/O worker lain telah tuntas atau belum.

Solusi: Atomic Write via Tempfile Rename dan Advisory Lock

Perbaikan membutuhkan dua lapis proteksi:

  1. POSIX rename(2): Tulis data lengkap ke file temporer di mount point yang sama, lalu timpa file target menggunakan operasi atomic rename. Pembaca akan selalu melihat versi lama yang utuh atau versi baru yang utuh, tanpa kondisi file kosong atau parsial.
  2. Advisory Locking (flock(2)): Sinkronisasi antar-harvester untuk memastikan operasi read-modify-write tidak saling menimpa riwayat penambahan skill (serialized execution).

Implementasi Backend Python

import os
import fcntl
import tempfile
from contextlib import contextmanager

LOCK_FILE_PATH = "/var/app/data/.agents_md.lock"
TARGET_FILE_PATH = "/var/app/data/AGENTS.md"

@contextmanager
def file_lock(lock_path: str):
    """Advisory lock menggunakan fcntl.flock untuk mengisolasi mutasi file."""
    lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
    try:
        fcntl.flock(lock_fd, fcntl.LOCK_EX)
        yield
    finally:
        fcntl.flock(lock_fd, fcntl.LOCK_UN)
        os.close(lock_fd)

def safe_atomic_update_agents(target_path: str, new_content: str):
    target_dir = os.path.dirname(os.path.abspath(target_path))
    
    # 1. Isolasi proses konkuren dengan advisory lock
    with file_lock(LOCK_FILE_PATH):
        # 2. Tulis ke file temporer di partisi/direktori yang sama
        with tempfile.NamedTemporaryFile(
            mode="w",
            dir=target_dir,
            delete=False,
            encoding="utf-8"
        ) as temp_file:
            temp_path = temp_file.name
            temp_file.write(new_content)
            temp_file.flush()
            os.fsync(temp_file.fileno())  # Pastikan blok data tertulis ke disk

        # 3. Ganti file target secara atomik (POSIX rename)
        # ponytail: os.replace mengasumsikan file ada di filesystem/mount yang sama
        os.replace(temp_path, target_path)

# Verifikasi self-check
if __name__ == "__main__":
    sample_data = "---\nversion: 1.0\nskills:\n  - code_analyzer\n---\n# Agents Config"
    safe_atomic_update_agents(TARGET_FILE_PATH, sample_data)
    with open(TARGET_FILE_PATH, "r", encoding="utf-8") as f:
        assert "code_analyzer" in f.read()

Mengapa Pendekatan Ini Bekerja

  • os.fsync(): Memaksa kernel melakukan flush dirty pages dari OS page cache ke media penyimpanan fisik sebelum modifikasi metadata direktori dilakukan.
  • os.replace(): Pada sistem POSIX, pemanggilan syscall rename(2) adalah operasi atomik. Jika pointer file target diganti saat reader sedang mengakses inode lama, reader tetap membaca salinan data lama sampai file descriptor ditutup. Tidak akan pernah terjadi status file terpotong (0 byte).
  • Named Lock File: Mengunci file terpisah (.agents_md.lock) alih-alih mengunci AGENTS.md langsung. Ini menghindari masalah inode dangling, di mana penggantian inode oleh os.replace membuat proses lain memegang lock pada inode lama yang sudah terlepas (unlinked).

Batasan dan Trade-Off

  • Cross-Device Links (EXDEV): os.replace() gagal jika file temporer dan target berada di mount point berbeda (misal, /tmp berada di tmpfs RAM dan data di SSD). Selalu definisikan dir=target_dir pada NamedTemporaryFile.
  • Distributed File Systems: flock(2) tidak sepenuhnya dapat diandalkan pada network filesystem lama (NFSv3). Untuk infrastruktur multi-node, alihkan mekanisme locking ke distributed lock manager seperti Redis (Redlock) atau etcd.