Skip to content

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

@code-yeongyu

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 (cause Invalid JSONL transaction: not valid JSON), 3 with Invalid JSONL storage s.jsonl: line 5 (cause Non-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: JsonlStorage currently backs the experimental server and session-worker surfaces (coding-agent/src/experimental/...), not the main CLI/RPC SessionManager path.
  • SessionManager already has the guard this lacks: tailMayBeTorn (session-manager.ts:1731) plus truncateToLastCompleteLine (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 / createBranchedSession copy entries with per-entry appendFileSync (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 :1698 deletes the whole new file, including the header and entries already on disk.

Expected (ideal state)

  1. 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; openV4 never 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.
  2. 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.
  3. ENOSPC/EDQUOT at a transcript write is classified and surfaced on every surface as "Disk full: free disk space, then continue this session".
  4. forkFrom builds the fork in a temp file and renames it (reuse replaceFileAtomically from session-file-replace.ts); a _writeHeaderAsync catch-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

Activity

  1. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    RED (part 1 of the fix: harness JsonlStorage) on main 96f8ac1, new test packages/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):

    × never refuses the session at any tear offset, keeps every committed record and never reuses a sequence
      Error: Invalid JSONL storage s.jsonl: line 4
      Caused by: Error: Invalid JSONL transaction: not valid JSON
    × reopens a file whose torn record was glued to the next one and keeps the records on both sides
      Error: Invalid JSONL storage s.jsonl: line 4
    × sets aside a line that is not JSON or reuses an earlier sequence, and keeps the records after it
      Error: Invalid JSONL storage s.jsonl: line 4
    × opens while the set-aside copy cannot be written, and repairs the file before the next append
    Tests  4 failed (4)
    

    Delivery plan, one behavior per PR: (A1) harness JsonlStorage torn-append repair, (A2) main-path SessionManager torn-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.

  2. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    RED (part 2: main-path SessionManager torn tail + one notice) on main 96f8ac1:

    • test/session-manager/torn-tail-recovery.test.ts and test/suite/regressions/4295-session-recovered-notice.test.ts: 5/5 fail. No .torn-* backup is kept, no recovery is reported, and no session_recovered event reaches any surface (expected [] to deeply equal [ 'unknown' ], expected [] to deeply equal [ 'disk_full' ]).
    • A further main-path defect found while writing these: SessionManager.continueRecent (-c) and setSessionFile never cut a torn tail, because the cut only ran in the static open/openAsync. The next append is glued onto the fragment and the new entry is lost on reload. Probe on main:
    TAIL="{\"type\":\"message\",\"id\":\"torn\",...\"con{\"type\":\"custom\",\"customType\":\"after\",...}\n"
    RELOADED customTypes=["seed"]     <- "after" is gone
    
  3. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    RED (part 3: typed disk-full error mid-turn) on main 96f8ac1, new test/suite/regressions/4295-disk-full-mid-turn.test.ts (an fs mock refuses session appends with ENOSPC / EDQUOT): 2/2 fail. The prompt rejects with the raw error and no cause:

    AssertionError: expected Error: ENOSPC: no space left on device, w… { code: '…' } to match object { code: 'ENOSPC', errorCause: 'disk_full', path: <session file> }
    AssertionError: expected Error: EDQUOT: disk quota exceeded, write { code: '…' } to match object { code: 'EDQUOT', errorCause: 'disk_full', … }
    
  4. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    RED (part 4: atomic fork + header catch-up keeps its prefix) on main 96f8ac1:

    FAIL test/session-manager/fork-full-disk.test.ts > leaves no fork file behind when forkFrom runs out of space part-way
      AssertionError: expected [ …(3) ] to deeply equal []      <- three half-copied forks left in the session directory
    FAIL test/session-manager-header-write.test.ts > keeps the header and entries already on disk when only the catch-up append fails, and writes the rest next
      AssertionError: expected [] to deeply equal [ 'session', 'before' ]   <- the failed catch-up deleted the whole file
    

    createBranchedSession already writes through the atomic _rewriteFile (#2367); its full-disk case passes on main and is kept as a guard test.

  5. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    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/setSessionFile a 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.

  6. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    GREEN (part 1, #4297) at head e8a1a24b44: CI green (20 pass, 14 skipped, 0 failed, including Check and test and Changelog 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.

  7. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    GREEN (part 2, #4300) at head f782ec8b10: CI green (20 pass, 14 skipped, 0 failed, including Check and test and 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.

  8. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    GREEN (part 4, #4305, carries Fixes #4295) at head cc1991a8e3: CI green (20 pass, 14 skipped, 0 failed, including Check and test and 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 _persist flushed-append block).

  9. code-yeongyu commented on Oct 26, 2026

    @code-yeongyu
    OwnerAuthor

    GREEN (part 3, #4303) at head 645c1b19d1: CI green (39 pass, 4 skipped, 0 failed, including Check 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions