Skip to content

fix(opencode): cap snapshot patches at 1000 files, summary diffs at ~1MB#18925

Open
BYK wants to merge 16 commits intoanomalyco:devfrom
BYK:byk/fix-snapshot-cap
Open

fix(opencode): cap snapshot patches at 1000 files, summary diffs at ~1MB#18925
BYK wants to merge 16 commits intoanomalyco:devfrom
BYK:byk/fix-snapshot-cap

Conversation

@BYK
Copy link
Contributor

@BYK BYK commented Mar 24, 2026

Issue for this PR

Closes #18921

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Prevents oversized snapshot patches and summary diffs when worktrees contain massive numbers of files (e.g. 99K+ files from .test-tmp directories).

snapshot/index.ts — Relative paths + count cap

  • patch() now stores relative paths from git instead of joining with state.worktree. Saves ~55 bytes per entry (the repeated worktree prefix).
  • Caps at 1,000 files with a warning log when exceeded.
  • revert() handles both old (absolute) and new (relative) paths via path.isAbsolute() for backward compat with existing DB entries.

session/summary.ts — ~1 MB byte budget

  • Replaces the arbitrary 1,000-entry count cap with a ~1 MB byte budget based on actual before + after content size.
  • capDiffs() accumulates FileDiff entries until content exceeds 1 MB, always including at least 1 entry.
  • Applied in both summarizeSession() and summarizeMessage().

Why byte budget > count cap for diffs

Each FileDiff carries full before/after file content. With a count cap:

  • 10 large files (50KB each) = 1MB — more than 1000 tiny files
  • 1000 entries × full content can still be 10-50 MB

The byte budget caps the actual payload size regardless of file count or individual file sizes.

How did you verify your code works?

  • Unit tests updated: all snapshot test assertions changed from absolute to relative paths
  • Verified binary builds successfully with snapshot cap in place
  • Tested with large worktrees (99K+ files) that previously caused multi-MB payloads

Checklist

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

@github-actions github-actions bot added the needs:compliance This means the issue will auto-close after 2 hours. label Mar 24, 2026
@BYK BYK changed the title fix(snapshot): cap patch file count at 1000 to prevent oversized parts fix(opencode): cap snapshot patch file count at 1000 Mar 24, 2026
@github-actions github-actions bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Mar 24, 2026
@github-actions
Copy link
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@BYK BYK force-pushed the byk/fix-snapshot-cap branch 2 times, most recently from 8cd1a62 to fc19cd1 Compare March 26, 2026 11:55
@BYK BYK requested a review from adamdotdevin as a code owner March 26, 2026 11:55
@github-actions github-actions bot added the needs:compliance This means the issue will auto-close after 2 hours. label Mar 26, 2026
@BYK BYK changed the title fix(opencode): cap snapshot patch file count at 1000 fix(opencode): cap snapshot patches at 1000 files, summary diffs at ~1MB Mar 26, 2026
@BYK BYK force-pushed the byk/fix-snapshot-cap branch 3 times, most recently from 5f7c1ae to 61ed31a Compare March 26, 2026 12:54
@github-actions github-actions bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Mar 26, 2026
@github-actions
Copy link
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

BYK added 15 commits March 26, 2026 15:59
Add interactive-widget=resizes-content to the viewport meta tag so the
layout viewport (and dvh) shrinks when the virtual keyboard opens. The
existing flex layout then keeps the composer visible above the keyboard.
Session.touch() now clears time_archived so that sending a prompt to an
archived session automatically unarchives it and places it back in the
sidebar.

As defense in depth, the frontend event-reducer no longer calls
cleanupSessionCaches() when a session.updated event arrives with
time.archived set. Archive is reversible — caches are harmless to keep
and will be reclaimed naturally. session.deleted still cleans caches.
meta.limit grows monotonically via loadMore() as the user scrolls up
through message history (200 → 400 → 600 → ...). When switching sessions
and returning, the 15s TTL expires and triggers a force-refresh that
reuses this inflated limit as the fetch size — re-downloading the entire
browsing history from scratch on every session switch.

Cap the limit to messagePageSize (200) on force-refreshes so only the
latest page is fetched. loadMore() still works normally after returning
since loadMessages() resets meta.cursor to match the fresh page.
…ded dir walk

Three changes to reduce server event loop contention when many projects
load concurrently (e.g. opening ~/Code with 17 sidebar projects):

1. Instance bootstrap concurrency limit (p-limit, N=3):
   Each Instance bootstrap spawns 5-7 subprocesses (git, ripgrep,
   parcel/watcher). With 17 concurrent bootstraps that's ~100 subprocesses
   overwhelming a 4-core SATA SSD system. The p-limit semaphore gates new
   directory bootstraps while allowing cache-hit requests through instantly.
   Peak subprocesses: ~100 → ~18.

2. Async Filesystem.exists and isDir:
   Filesystem.exists() wrapped existsSync() in an async function — it
   looked async but blocked the event loop on every call. Replaced with
   fs.promises.access(). Same for isDir (statSync → fs.promises.stat).
   This lets the event loop serve health checks and API responses while
   directory walk-ups are in progress. Added Filesystem.existsSync() for
   callers that genuinely need sync behavior.

3. Bounded Filesystem.up/findUp walk depth (maxDepth=10):
   Previously walked all the way to / with no limit. Added maxDepth
   parameter (default 10) as a safety net against degenerate deep paths.
…g in memory

Previously, bash tool and /shell command accumulated ALL stdout/stderr
in an unbounded string — a verbose command could grow to hundreds of MB.

Now output beyond Truncate.MAX_BYTES (50KB) is streamed directly to a
spool file on disk. Only the first 50KB is kept in memory for the
metadata preview. The full output remains recoverable via the spool file
path included in the tool result metadata.

Includes test for the spooling behavior.
The edit tool's fuzzy matching uses Levenshtein distance for similarity
scoring. The old implementation allocated a full (n+1)×(m+1) matrix
which could spike to ~400MB for 10K character strings.

Replace with a standard 2-row algorithm that swaps the shorter string
to the inner loop. Produces identical results with dramatically less
memory — O(min(n,m)) instead of O(n×m).
When compaction marks a tool part as compacted, toModelMessages() already
skips it — but the full output string (up to 50KB per part) was still
stored in SQLite as dead weight.

Now set output to '[compacted]', clear metadata and attachments when
compacting. This reduces database bloat over time.
- FileTime: add remove() to clean up per-session read timestamps
- LSP client: delete diagnostics map entries when empty instead of
  storing empty arrays (prevents monotonic growth)
- RPC: add 60s timeout to pending calls so orphaned promises from
  dead workers are cleaned up instead of leaking indefinitely
Add configurable session retention that auto-deletes archived sessions
older than retention.days (default: 30, 0 = disabled). Runs every 6
hours via Scheduler, batched at 100 sessions per run.

Also call FileTime.remove() on session archive and delete to clean up
per-session read timestamps.

The database had grown to 1.99GB with 1,706 sessions spanning 53 days
because there was no automatic cleanup — sessions were only soft-archived
but never deleted.
Build on anomalyco#19299 by @thdxr with several production hardening improvements:

- Filter optional fonts during embedding (~27MB savings) — only core fonts
  (Inter + IBM Plex Mono) are embedded, the rest fall through to CDN proxy
- Add pre-bootstrap static middleware before Instance.provide() to avoid
  DB migration checks on every asset request
- Add SPA fallback routing — return index.html for page routes but not for
  asset requests (which fall through to CDN proxy for excluded fonts)
- Add OPENCODE_APP_DIR env var for dev override
- Auto-detect packages/app/dist in monorepo dev mode
- Use explicit MIME type map instead of Bun.file().type for consistent typing
- CDN proxy fallback for any asset not found locally
Two optimizations to drastically reduce memory during prompting:

1. filterCompactedLazy: probe newest 50 message infos (1 query, no
   parts) to detect compaction. If none found, fall back to original
   single-pass filterCompacted(stream()) — avoids 155+ wasted info-only
   queries for uncompacted sessions. Compacted sessions still use the
   efficient two-pass scan.

2. Context-window windowing: before calling toModelMessages, estimate
   which messages from the tail fit in the LLM context window using
   model.limit.context * 4 chars/token. Only convert those messages to
   ModelMessage format. For a 7,704-message session where ~200 fit in
   context, this reduces toModelMessages input from 7,704 to ~200
   messages — cutting ~300MB of wrapper objects across 4-5 copy layers
   down to ~10MB.

Also caches conversation across prompt loop iterations — full reload
only after compaction, incremental merge for tool-call steps.
… minimize

Two additional fixes:

1. Add plan_exit/plan_enter to the tools disable map in task.ts.
   The session permissions were being overwritten by SessionPrompt.prompt()
   which converts the tools map into session permissions. Without plan_exit
   in the tools map, it wasn't being denied.

2. Add minimize/expand toggle to the question dock so users can collapse
   it to read the conversation while a question is pending. Adds a
   chevron button in the header and makes the title clickable to toggle.
   DockPrompt gains a minimized prop that hides content and footer.
…nder as markdown

Three fixes for experimental planning mode:

1. Deny plan_exit/plan_enter permissions on subagent sessions so
   explore/general agents spawned by the plan agent cannot accidentally
   trigger a mode switch to build.

2. Read the plan file content in PlanExitTool and embed it in the
   question text so users see the full plan before deciding to switch.

3. Render question text with the Markdown component instead of raw
   text, both in the live question dock and historical message parts.
   Add overflow-y/max-height to question-text CSS so long plans scroll
   within the dock.
- One-time migration to incremental auto-vacuum (PRAGMA auto_vacuum=2)
  so disk space is reclaimed when sessions are deleted
- Add Database.checkpoint() (TRUNCATE mode) and Database.vacuum()
  (incremental_vacuum(500)) helpers
@BYK BYK force-pushed the byk/fix-snapshot-cap branch from 61ed31a to 690ef2b Compare March 26, 2026 16:00
Snapshot.patch() now stores relative paths (instead of joining with worktree
root) and caps at 1000 entries. Revert handles both old absolute and new
relative paths via path.isAbsolute() for backward compat.

Summary diffs (summarizeSession/summarizeMessage) use a ~1MB byte budget
based on actual before+after content size instead of an arbitrary count cap.
This prevents multi-MB payloads while allowing many small file diffs.

Closes anomalyco#18921
@BYK BYK force-pushed the byk/fix-snapshot-cap branch from 690ef2b to a944246 Compare March 26, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Snapshot patch can produce multi-MB parts with 100K+ file entries

1 participant