fix(opencode): skip apply_patch diffs for binary and large deletes

Merged
CO
4d ago
+97-67

Stops apply_patch from running a sandbox out of memory when it deletes a large binary file. A delete of a binary file, or of any file over 1 MiB, now records only its size in tool metadata instead of a full diff of its contents.

Context: REPL-33048 (found in the media-to-Assets eval). Upstream report: anomalyco/opencode#41733, closed unfixed as "V1". This ports #41734, which was closed unmerged. Write-up: Large File Delete Crash.

Symptom

A GPT-model agent asked to move a 30 MiB video out of public/ deleted it with one apply_patch (*** Delete File: public/videos/brand-film.mp4). The sandbox's agent process was then killed and restarted mid-turn ([opencode-exit] {"signal":9,"oomKill":1,"memPeakMib":4096}), and the user saw "The agent restarted before it could finish this turn." It happened in 6 of 6 eval runs of that scenario. Only GPT models are affected, because only they get apply_patch (tool/registry.ts). Production last week: 2,503 of 2,504 apply_patch calls were GPT-5.6.

Cause

The delete branch read the whole file, decoded it as UTF-8, and built createTwoFilesPatch(content, ""). That diff went into tool metadata twice (metadata.diff and metadata.files[].patch). Decoding and diffing are cheap, about 200 MB for 30 MiB. Serializing the tool part is not: every control byte in a binary becomes a six-character \u00XX escape, so one JSON copy of the part was 347 MiB, and producing it took about 2 GB of memory. A couple of copies (the stored part, the event stream) exceed a 4 GiB sandbox. The model never sees any of this; its output is just D <path>.

Fix

In the delete case, stat the file first. If it is over 1 MiB, don't read it. If it is smaller, read it and run the read tool's existing isBinaryFile check. Binary or oversized deletes get a two-line marker diff, Index: <path> and Binary or oversized file deleted (<bytes> bytes; content diff omitted), with deletions: 0. Small text deletes keep their full diff. isBinaryFile moves out of read.ts into util/binary.ts so both tools share it; its behavior is unchanged. Missing files and directories still fail.

V2 has the same code path (packages/core/src/tool/plugin/patch.ts delete → fileDiff), so this guard needs re-applying there during the V2 migration.

How did you verify your code works?

Unit tests (bun test test/tool/apply_patch.test.ts test/tool/read.test.ts): 68 pass, and bun run typecheck passes.

  • One new test deletes a 512 KiB .mp4 (NUL bytes, size under the cap, so the content check catches it) and a text file just over 1 MiB in one patch. It asserts the metadata stays under 1 KB with deletions: 0. It fails on dev without this change.
  • The existing add/update/delete test now also asserts the small text delete keeps its line count.

End to end on a real Daytona sandbox (staging eval stack ev-7ac634c081). Linux x64 builds of this branch were installed over the sandbox's opencode. The publish run used commit 3f96871; the 30 MiB delete was rerun on the final commit ab9a666 (sha256 8d8a5c60…).

RunBefore (v1.18.32-9)This branch
GPT agent deletes the 30 MiB video with apply_patchagent 0.7 → 3.4 GiB in 3 s, OOM-killed, restartedsame pid, peak 0.85 GiB, file deleted, tool metadata 528 bytes
GPT agent deletes a 2-line text file with apply_patchnot runfull diff kept (-alpha line, -beta line)
Real turn through the stack coordinator: "Publish my site please." with the 30 MiB video in public/ and the publish gate installedrestarted mid-fix in 6 of 6 eval runsgate refused → upload to Assets → one apply_patch (update BrandFilm.tsx + delete the mp4) → publish succeeded; same agent pid across all 477 memory samples (peak 0.93 GiB); live site returns 200 and serves the video from Assets

Type of change

  • Bug fix

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

🤖 Generated with Claude Code