Repository navigation
ENOSPC: JsonlStorage refuses the whole session after a torn append + retry; torn-tail recovery is silent; no actionable disk-full error outside the crash handler #4295
Description
Summary
When a write fails with ENOSPC (disk full), some session write paths leave state that either refuses the whole session later or silently loses data, and the user never gets an actionable message outside the interactive crash handler. The most serious case: the harness v4 JsonlStorage refuses to open the WHOLE session if the same process retried a commit after a torn append.
Reported by a user: a long loop hit "no space left on device"; after freeing disk space, the same session would not start or resume. Their surface and exact error are still being collected; the defects below were reproduced independently with a simulated ENOSPC (a writer stub that lands N bytes and then throws), never a real full disk.
Defects
1. JsonlStorage glues the next commit onto a torn append, then refuses the session (confirmed)
packages/agent/src/harness/session/jsonl/storage.ts:138-150(applyCommit): appends with no torn-tail guard. After an append fails part-way, the next commit in the same process appends right after the fragment, e.g. line 4 on disk is{"kind":"entry","{"kind":"entry","id":"e4",...}.- The failed commit's sequence number is reused (the commit is prepared but the counter is not advanced), so when the bytes landed but the write reported failure, the next commit duplicates the seq.
openV4(storage.ts:98-106) throws on the first bad INTERIOR line; only a torn FINAL line is repaired (storage.ts:108).- Repro: torn append, disk freed, same process commits again, reopen. 159 of 159 tear offsets refuse the session: 156 with
Invalid JSONL storage s.jsonl: line 4(causeInvalid JSONL transaction: not valid JSON), 3 withInvalid JSONL storage s.jsonl: line 5(causeNon-monotonic storage sequence: 3). - Control: a torn tail followed by a process exit reopens fine (the tail is repaired). A clean file cut at every byte reopens with its complete prefix, except a missing/partial header (
missing header). - Reach:
JsonlStoragecurrently backs the experimental server and session-worker surfaces (coding-agent/src/experimental/...), not the main CLI/RPCSessionManagerpath. SessionManageralready has the guard this lacks:tailMayBeTorn(session-manager.ts:1731) plustruncateToLastCompleteLine(session-write-recovery.ts).
2. A dropped torn tail is never reported
On the main path, a torn tail is cut on open (terminateFinalSessionLine, session-entry-file.ts:160) and malformed lines are skipped (parseSessionEntryLine, session-entry-file.ts:23). The user is never told, so "my last turn is gone" reads as "the session will not resume".
3. ENOSPC mid-turn surfaces raw
The prompt rejects with ENOSPC: no space left on device, write plus a transcript_write_failed event. The actionable "Disk full (ENOSPC). Free space on the filesystem, then retry." text (interactive-mode.ts:567) is only used by the uncaught-crash handler, so RPC and desktop users never see it.
4. Non-atomic whole-file writes
forkFrom/createBranchedSessioncopy entries with per-entryappendFileSync(session-manager.ts:3096); an ENOSPC leaves a half-copied fork in the session list._writeHeaderAsync: if the post-close catch-up append fails (session-manager.ts:1693), the cleanup at:1698deletes the whole new file, including the header and entries already on disk.
Expected (ideal state)
JsonlStorage: after a failed append the tail is marked suspect and cut to the last complete line before the next append; a failed commit's seq is never reused;openV4never refuses a session because of one torn record: it skips the torn fragment, keeps every valid transaction before and after it, and rewrites the repaired file atomically once.- Any open that drops a torn tail or skips malformed lines emits ONE structured notice (e.g. "Recovered session: dropped 1 incomplete record written during a failed save"), shown once on every surface (TUI, RPC, desktop). The valid prefix is kept byte for byte.
- ENOSPC/EDQUOT at a transcript write is classified and surfaced on every surface as "Disk full: free disk space, then continue this session".
forkFrombuilds the fork in a temp file and renames it (reusereplaceFileAtomicallyfromsession-file-replace.ts); a_writeHeaderAsynccatch-up failure keeps the already-written prefix (cut to the last complete line) instead of deleting the file.
Acceptance criteria
- RED then GREEN: the same-process retry repro (every tear offset) reopens with all complete records; no seq duplicates.
- A test per surface that the recovery notice is emitted exactly once.
- A test that an ENOSPC during a turn yields the typed disk-full error on the RPC/json stream.
- A fork interrupted by ENOSPC leaves no fork file in the session list; a failed header catch-up leaves the written prefix.
- All tests use a simulated ENOSPC in a temp dir.
Related
- fix(coding-agent): rewrite session files atomically so a failed migration keeps the original #2367 (atomic migration rewrite), fix(coding-agent): a refused delivery, header or release write never leaves a session stuck #2370 (part-written async header removed), fix(coding-agent): explain disk-capacity crashes #1184 (ENOSPC guidance in the crash path), Pending input lifecycle differs across TUI and RPC and loses durable outcomes #3549 (torn final line cut on open)
- fix(omo-senpi): isolate storage-full wake listeners oh-my-openagent#7549 (storage-full wake listeners), [Feature]: Resource-aware admission control - validate free disk/memory/load before admitting heavy parallel tasks oh-my-openagent#9753 (disk-aware admission)
Activity
code-yeongyu commentedon Oct 26, 2026
RED (part 1 of the fix: harness
JsonlStorage) onmain96f8ac1, new testpackages/agent/test/harness/jsonl-storage-torn-append.test.ts(simulated ENOSPC: a FileSystem whose next append lands N characters and then fails with an ENOSPC-coded error; temp dirs only):Delivery plan, one behavior per PR: (A1) harness
JsonlStoragetorn-append repair, (A2) main-pathSessionManagertorn-tail backup + one typed recovery notice, (B) typed disk-full error mid-turn, (C) atomic fork + header catch-up keeps the written prefix. omo side: code-yeongyu/oh-my-openagent#11361.code-yeongyu commentedon Oct 26, 2026
RED (part 2: main-path
SessionManagertorn tail + one notice) onmain96f8ac1:test/session-manager/torn-tail-recovery.test.tsandtest/suite/regressions/4295-session-recovered-notice.test.ts: 5/5 fail. No.torn-*backup is kept, no recovery is reported, and nosession_recoveredevent reaches any surface (expected [] to deeply equal [ 'unknown' ],expected [] to deeply equal [ 'disk_full' ]).SessionManager.continueRecent(-c) andsetSessionFilenever cut a torn tail, because the cut only ran in the staticopen/openAsync. The next append is glued onto the fragment and the new entry is lost on reload. Probe onmain:code-yeongyu commentedon Oct 26, 2026
RED (part 3: typed disk-full error mid-turn) on
main96f8ac1, newtest/suite/regressions/4295-disk-full-mid-turn.test.ts(anfsmock refuses session appends with ENOSPC / EDQUOT): 2/2 fail. The prompt rejects with the raw error and no cause:code-yeongyu commentedon Oct 26, 2026
RED (part 4: atomic fork + header catch-up keeps its prefix) on
main96f8ac1:createBranchedSessionalready writes through the atomic_rewriteFile(#2367); its full-disk case passes onmainand is kept as a guard test.code-yeongyu commentedon Oct 26, 2026
Reporter details (the original report): the surface is the desktop app (2026.1025.3), running engine senpi 2026.10.23-1. The exact resume error text is still being collected.
Implication: the desktop drives the main-path session through the RPC host, not the experimental JsonlStorage, so candidate 1 (the JsonlStorage refusal) is unlikely for this report. Remaining candidates: (2) the engine process that hit ENOSPC still holds the session (session holder record) and a new open is refused until it exits; (3) the session opens but its last turn is gone; and the defect found in #4300: on
continueRecent/setSessionFilea torn tail was never cut, so the next save glued onto the fragment and was lost on reload. #4300 fixes that path. The engine version predates all of these fixes.code-yeongyu commentedon Oct 26, 2026
GREEN (part 1, #4297) at head
e8a1a24b44: CI green (20 pass, 14 skipped, 0 failed, includingCheck and testandChangelog gate). The local senpi-static receipt at the same head is green (install, check, typecheck, changes, build, supervisor-import-graph). The PR is out of draft.fix(coding-agent): keep torn-tail backups off new blocking calls on t…
fix(coding-agent): drop the blocking free-space probe from the disk-f…
fix(coding-agent): no extra existence probe in forkFrom; drop its ret…
code-yeongyu commentedon Oct 26, 2026
GREEN (part 2, #4300) at head
f782ec8b10: CI green (20 pass, 14 skipped, 0 failed, includingCheck and testand both RPC no-sync audits). The senpi-static receipt at the same head is green. The PR is out of draft. Merge order: after #4297 and #4303; #4305 rebases after this.code-yeongyu commentedon Oct 26, 2026
GREEN (part 4, #4305, carries Fixes #4295) at head
cc1991a8e3: CI green (20 pass, 14 skipped, 0 failed, includingCheck and testand both RPC no-sync audits). The senpi-static receipt at the same head is green. The PR is out of draft. It merges last, after #4300, and rebases on it (same_persistflushed-append block).code-yeongyu commentedon Oct 26, 2026
GREEN (part 3, #4303) at head
645c1b19d1: CI green (39 pass, 4 skipped, 0 failed, includingCheck and test, the Windows matrix and both RPC no-sync audits). The senpi-static receipt at the same head is green. The PR is out of draft. Merge order: second, after #4297.fix(coding-agent): keep recovery paths private (#4295)
fix(coding-agent): unify transcript write tracking (#4295)