Append-only writer for the JSONL transcript at ``path``. Opens an ``O_APPEND`` file descriptor on construction. Each ``append(message)`` call serializes one line and emits it via a single ``os.write`` so concurrent writers (e.g. the same agent across crash-restart, or a future multi
| 155 | |
| 156 | |
| 157 | class TranscriptWriter: |
| 158 | """Append-only writer for the JSONL transcript at ``path``. |
| 159 | |
| 160 | Opens an ``O_APPEND`` file descriptor on construction. Each |
| 161 | ``append(message)`` call serializes one line and emits it via a |
| 162 | single ``os.write`` so concurrent writers (e.g. the same agent |
| 163 | across crash-restart, or a future multi-writer scenario) cannot |
| 164 | interleave bytes at sub-PIPE_BUF line sizes. |
| 165 | |
| 166 | The writer is **synchronous** (file IO, not asyncio). Per the A6/C5 |
| 167 | contract, callers must NOT hold ``RuntimeTaskRegistry``'s RLock |
| 168 | across an ``append()`` call: while file IO is fast, blocking under |
| 169 | the registry's lock would deadlock the asyncio scheduler against |
| 170 | bash worker threads. |
| 171 | """ |
| 172 | |
| 173 | def __init__(self, path: str | Path) -> None: |
| 174 | self._path = str(path) |
| 175 | # Ensure parent dir exists (transcript root may not have been |
| 176 | # created yet if the caller bypassed ``get_agent_transcript_path``). |
| 177 | Path(self._path).parent.mkdir(parents=True, exist_ok=True) |
| 178 | # ``O_APPEND`` makes every write atomic at the file-position |
| 179 | # level. ``O_CLOEXEC`` keeps the fd from leaking to bash |
| 180 | # subprocesses. ``0o600`` because transcripts can contain |
| 181 | # sensitive prompt content — readable by the user only. |
| 182 | self._fd: int | None = os.open( |
| 183 | self._path, |
| 184 | os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_CLOEXEC, |
| 185 | 0o600, |
| 186 | ) |
| 187 | self._closed = False |
| 188 | |
| 189 | @property |
| 190 | def path(self) -> str: |
| 191 | return self._path |
| 192 | |
| 193 | def append(self, message: Any) -> None: |
| 194 | """Append one message as a UTF-8 JSON line, terminated with ``\\n``. |
| 195 | |
| 196 | Crash-safety: a single ``os.write`` of the line ensures the |
| 197 | writer either appends the whole line or nothing. POSIX |
| 198 | guarantees this for sizes ≤ ``PIPE_BUF`` (≥4096 bytes on every |
| 199 | modern Unix); for larger lines the reader is tolerant of |
| 200 | partial trailing content per the JSONL format design. |
| 201 | """ |
| 202 | if self._closed or self._fd is None: |
| 203 | raise RuntimeError("TranscriptWriter is closed") |
| 204 | line = _serialize_message(message) + "\n" |
| 205 | encoded = line.encode("utf-8") |
| 206 | # ``os.write`` may short-write under specific OS conditions; |
| 207 | # loop until the whole buffer is on disk. For O_APPEND files |
| 208 | # the returned ``n`` is byte count of THIS write, so the loop |
| 209 | # is straightforward. |
| 210 | view = memoryview(encoded) |
| 211 | while view: |
| 212 | written = os.write(self._fd, view) |
| 213 | if written <= 0: |
| 214 | # Defensive: 0-byte returns shouldn't happen on regular |
no outgoing calls