Skip to content

fix(flue-review): elide oversized diff sections before staging for the model - #2393

Merged
ascorbic merged 2 commits into
mainfrom
fix/flue-review-diff-budget
Aug 10, 2026
Merged

fix(flue-review): elide oversized diff sections before staging for the model#2393
ascorbic merged 2 commits into
mainfrom
fix/flue-review-diff-budget

Conversation

@ascorbic

@ascorbic ascorbic commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes the review failure on #2392 (model_review FlueError): the review agent's first step is a whole-file read of the staged unified diff, so a PR whose diff is dominated by large generated files (#2392 stages ~29k lines of worker-configuration.d.ts) lands the entire diff in the model context and kills the model call.

The staged diff now passes through elideLargeDiffSections before being written to the workspace: any per-file section over 48 KiB is reduced to its header plus a note telling the agent to read that file from the checkout, and if the diff still exceeds a 384 KiB total budget the largest remaining sections are elided until it fits. Small diffs pass through byte-identical.

Already deployed; this PR's own review runs on the fixed worker.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

Changeset and i18n are n/a: private infra/flue-review worker only. Typecheck run for the package (tsc --noEmit with generated worker types); tests are the flue-review vitest suite (54 passing, four new for the elision behavior).

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5 (Claude Code)

Screenshots / test output

 Test Files  6 passed (6)
      Tests  54 passed (54)

Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks fix/flue-review-diff-budget. Updated automatically when the playground redeploys.

Copilot AI lite review requested due to automatic review settings August 9, 2026 09:14
@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 3ca1027

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 3ca1027 Aug 10 2026, 06:57 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do 3ca1027 Aug 10 2026, 06:57 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 9, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 3ca1027 Aug 10 2026, 06:59 AM

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right fix for the model-review outage: staging an elided diff preserves the review agent's workflow (the SKILL.md already tells the agent to read full files from the checkout) while preventing generated files from blowing up the context window. The size-based heuristic is simple, general, and fits the worker-only architecture.

I read the full diff, infra/flue-review/.flue/lib/diff-budget.ts, review.ts, the new tests, tsconfig.json, and package.json. Most of the change is clean and well-tested. However, I found one latent logic bug in elide(): it can return early without marking a section as reduced, which the total-budget loop assumes has happened. That can spin forever on a section whose format lacks a recognizable +++/@@ header. Since this PR is itself a fix for a production review failure, leaving a path to a Worker CPU-timeout hang is worth fixing before merge. A small refactoring and regression test cover it.

The package is private, so no changeset is required, and the i18n/RTL/SQL conventions in AGENTS.md don't apply to this internal worker. ES2023 target is configured, so Array.prototype.toSorted() typechecks correctly.

Comment on lines +20 to +72
export function elideLargeDiffSections(diff: string, budget: DiffBudget = {}): string {
const perFileBytes = budget.perFileBytes ?? DEFAULT_PER_FILE_BYTES;
const totalBytes = budget.totalBytes ?? DEFAULT_TOTAL_BYTES;
if (diff.length <= Math.min(perFileBytes, totalBytes)) return diff;

const sections = splitSections(diff);
for (const section of sections) {
if (section.text.length > perFileBytes) elide(section);
}
// Still over the total budget: elide the largest remaining sections until
// under it (or nothing left to elide).
let total = sections.reduce((n, s) => n + s.text.length, 0);
while (total > totalBytes) {
const next = sections
.filter((s) => !s.elided)
.toSorted((a, b) => b.text.length - a.text.length)[0];
if (!next) break;
total -= next.text.length;
elide(next);
total += next.text.length;
}
return sections.map((s) => s.text).join("");
}

function splitSections(diff: string): Section[] {
const starts: number[] = [];
const re = /^diff --git /gm;
for (let m = re.exec(diff); m; m = re.exec(diff)) starts.push(m.index);
if (starts.length === 0) return [{ text: diff, elided: false }];
const sections: Section[] = [];
if (starts[0] !== 0) sections.push({ text: diff.slice(0, starts[0]), elided: true });
for (let i = 0; i < starts.length; i++) {
const end = i + 1 < starts.length ? starts[i + 1] : diff.length;
sections.push({ text: diff.slice(starts[i], end), elided: false });
}
return sections;
}

function elide(section: Section): void {
const lines = section.text.split("\n");
// Keep the file header: everything up to and including the `+++` line (or
// the whole header for binary/rename-only sections with no hunks).
let headerEnd = lines.findIndex((line) => line.startsWith("+++ "));
if (headerEnd === -1) headerEnd = lines.findIndex((line) => line.startsWith("@@ ")) - 1;
if (headerEnd < 0) return;
const body = lines.length - (headerEnd + 1);
section.text = [
...lines.slice(0, headerEnd + 1),
`(diff content elided: ${body} lines over the size budget -- read this file from the checkout instead)`,
"",
].join("\n");
section.elided = true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] elide() can return without setting section.elided, and the loops assume every processed section is marked reduced. If a diff section lacks a +++/@@ header and is large enough to be selected by the total-budget while loop, it is selected again on the next iteration because section.elided never flips to true, so total never shrinks and the Worker spins until its execution limit. Malformed or unusual formats (e.g. a headerless file section, a very large mode-only diff, or a binary patch shaped differently than expected) are enough to trigger this.

The per-file loop also calls elide() on the preface section even though it was already flagged elided: true in splitSections, which is a hint that elided is being overloaded.

Fix by (1) guarding the per-file loop so it only tries to reduce sections that aren't already marked, and (2) making elide() always mark the section and fall back to replacing the whole section when no header boundary is found.

Suggested change
export function elideLargeDiffSections(diff: string, budget: DiffBudget = {}): string {
const perFileBytes = budget.perFileBytes ?? DEFAULT_PER_FILE_BYTES;
const totalBytes = budget.totalBytes ?? DEFAULT_TOTAL_BYTES;
if (diff.length <= Math.min(perFileBytes, totalBytes)) return diff;
const sections = splitSections(diff);
for (const section of sections) {
if (section.text.length > perFileBytes) elide(section);
}
// Still over the total budget: elide the largest remaining sections until
// under it (or nothing left to elide).
let total = sections.reduce((n, s) => n + s.text.length, 0);
while (total > totalBytes) {
const next = sections
.filter((s) => !s.elided)
.toSorted((a, b) => b.text.length - a.text.length)[0];
if (!next) break;
total -= next.text.length;
elide(next);
total += next.text.length;
}
return sections.map((s) => s.text).join("");
}
function splitSections(diff: string): Section[] {
const starts: number[] = [];
const re = /^diff --git /gm;
for (let m = re.exec(diff); m; m = re.exec(diff)) starts.push(m.index);
if (starts.length === 0) return [{ text: diff, elided: false }];
const sections: Section[] = [];
if (starts[0] !== 0) sections.push({ text: diff.slice(0, starts[0]), elided: true });
for (let i = 0; i < starts.length; i++) {
const end = i + 1 < starts.length ? starts[i + 1] : diff.length;
sections.push({ text: diff.slice(starts[i], end), elided: false });
}
return sections;
}
function elide(section: Section): void {
const lines = section.text.split("\n");
// Keep the file header: everything up to and including the `+++` line (or
// the whole header for binary/rename-only sections with no hunks).
let headerEnd = lines.findIndex((line) => line.startsWith("+++ "));
if (headerEnd === -1) headerEnd = lines.findIndex((line) => line.startsWith("@@ ")) - 1;
if (headerEnd < 0) return;
const body = lines.length - (headerEnd + 1);
section.text = [
...lines.slice(0, headerEnd + 1),
`(diff content elided: ${body} lines over the size budget -- read this file from the checkout instead)`,
"",
].join("\n");
section.elided = true;
}
for (const section of sections) {
if (!section.elided && section.text.length > perFileBytes) elide(section);
}
Suggested change
export function elideLargeDiffSections(diff: string, budget: DiffBudget = {}): string {
const perFileBytes = budget.perFileBytes ?? DEFAULT_PER_FILE_BYTES;
const totalBytes = budget.totalBytes ?? DEFAULT_TOTAL_BYTES;
if (diff.length <= Math.min(perFileBytes, totalBytes)) return diff;
const sections = splitSections(diff);
for (const section of sections) {
if (section.text.length > perFileBytes) elide(section);
}
// Still over the total budget: elide the largest remaining sections until
// under it (or nothing left to elide).
let total = sections.reduce((n, s) => n + s.text.length, 0);
while (total > totalBytes) {
const next = sections
.filter((s) => !s.elided)
.toSorted((a, b) => b.text.length - a.text.length)[0];
if (!next) break;
total -= next.text.length;
elide(next);
total += next.text.length;
}
return sections.map((s) => s.text).join("");
}
function splitSections(diff: string): Section[] {
const starts: number[] = [];
const re = /^diff --git /gm;
for (let m = re.exec(diff); m; m = re.exec(diff)) starts.push(m.index);
if (starts.length === 0) return [{ text: diff, elided: false }];
const sections: Section[] = [];
if (starts[0] !== 0) sections.push({ text: diff.slice(0, starts[0]), elided: true });
for (let i = 0; i < starts.length; i++) {
const end = i + 1 < starts.length ? starts[i + 1] : diff.length;
sections.push({ text: diff.slice(starts[i], end), elided: false });
}
return sections;
}
function elide(section: Section): void {
const lines = section.text.split("\n");
// Keep the file header: everything up to and including the `+++` line (or
// the whole header for binary/rename-only sections with no hunks).
let headerEnd = lines.findIndex((line) => line.startsWith("+++ "));
if (headerEnd === -1) headerEnd = lines.findIndex((line) => line.startsWith("@@ ")) - 1;
if (headerEnd < 0) return;
const body = lines.length - (headerEnd + 1);
section.text = [
...lines.slice(0, headerEnd + 1),
`(diff content elided: ${body} lines over the size budget -- read this file from the checkout instead)`,
"",
].join("\n");
section.elided = true;
}
function elide(section: Section): void {
section.elided = true;
const lines = section.text.split("\n");
// Keep the file header: everything up to and including the `+++` line (or
// the whole header for binary/rename-only sections with no hunks).
let headerEnd = lines.findIndex((line) => line.startsWith("+++ "));
if (headerEnd === -1) headerEnd = lines.findIndex((line) => line.startsWith("@@ ")) - 1;
if (headerEnd < 0) {
section.text = `(diff content elided: ${lines.length} lines over the size budget -- read this file from the checkout instead)\n\n`;
return;
}
const body = lines.length - (headerEnd + 1);
section.text = [
...lines.slice(0, headerEnd + 1),
`(diff content elided: ${body} lines over the size budget -- read this file from the checkout instead)`,
"",
].join("\n");
}

Please also add a regression test in infra/flue-review/test/diff-budget.test.ts that passes a large headerless section and asserts the budget loop terminates and replaces the section instead of returning the original content.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond size/M labels Aug 9, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses Flue review failures caused by oversized staged unified diffs (e.g., large generated type files) by eliding large per-file diff sections and enforcing an overall diff-size budget before the model reads the diff from the workspace.

Changes:

  • Add elideLargeDiffSections() utility to shrink oversized per-file diff sections to headers + an elision notice, and optionally elide additional sections until under a total-size budget.
  • Apply the diff elision step when staging the unified diff into the review workspace.
  • Add a vitest suite covering small-diff passthrough, per-file elision, total-budget elision, and header-only sections.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
infra/flue-review/.flue/lib/diff-budget.ts Introduces diff section splitting + size-budget elision logic.
infra/flue-review/.flue/workflows/review.ts Routes the fetched unified diff through the new elision utility before writing to DIFF_PATH.
infra/flue-review/test/diff-budget.test.ts Adds tests validating elision behavior and invariants.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +58 to +72
function elide(section: Section): void {
const lines = section.text.split("\n");
// Keep the file header: everything up to and including the `+++` line (or
// the whole header for binary/rename-only sections with no hunks).
let headerEnd = lines.findIndex((line) => line.startsWith("+++ "));
if (headerEnd === -1) headerEnd = lines.findIndex((line) => line.startsWith("@@ ")) - 1;
if (headerEnd < 0) return;
const body = lines.length - (headerEnd + 1);
section.text = [
...lines.slice(0, headerEnd + 1),
`(diff content elided: ${body} lines over the size budget -- read this file from the checkout instead)`,
"",
].join("\n");
section.elided = true;
}
Comment on lines +48 to +60
it("leaves a header-only section (no hunks) alone", () => {
const rename = [
"diff --git a/old.ts b/new.ts",
"similarity index 100%",
"rename from old.ts",
"rename to new.ts",
"",
].join("\n");
const filler = fileSection("big.ts", 2_000);
const out = elideLargeDiffSections(rename + filler, { perFileBytes: 1_000, totalBytes: 1_500 });
expect(out).toContain("rename from old.ts");
});
});
@pkg-pr-new

pkg-pr-new Bot commented Aug 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2393

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2393

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2393

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2393

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2393

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2393

emdash

npm i https://pkg.pr.new/emdash@2393

create-emdash

npm i https://pkg.pr.new/create-emdash@2393

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2393

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2393

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2393

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2393

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2393

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2393

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2393

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2393

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2393

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2393

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2393

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2393

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2393

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2393

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2393

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2393

commit: 3ca1027

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 10, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Aug 10, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still looks like the right fix for the model-review outage: putting the staged diff through a size budget before writing it to the workspace protects the agent’s first read without breaking the review workflow, because the agent SKILL.md already tells the reviewer to read full files from the checkout.

I re-read the full diff, infra/flue-review/.flue/lib/diff-budget.ts, infra/flue-review/.flue/workflows/review.ts, and the new tests. The prior review’s latent-infinite-loop concern has been addressed: elide() now marks the section as elided before attempting to reduce it, and the new "skips an unreducible headerless section instead of looping on it" test exercises the exact path. The total-budget loop can therefore always make progress and will terminate.

The rest of the implementation (per-file threshold, total-budget pass, header-preserving reduction, ES2023 toSorted() usage) is unchanged in spirit and matches the PR description. The package is private, so no changeset is required, and the AGENTS.md i18n/RTL/SQL/etc. conventions do not apply to this internal worker.

I found no remaining correctness, security, or convention issues. LGTM.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 10, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Aug 10, 2026
@ascorbic
ascorbic merged commit 86ac15b into main Aug 10, 2026
49 checks passed
@ascorbic
ascorbic deleted the fix/flue-review-diff-budget branch August 10, 2026 08:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/approved Approved; no new commits since size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants