fix(coder): harden edit-apply pipeline against block duplication
Root cause: two proven corruption mechanisms — (M1) non-idempotent apply stamped the same block N times when a quantized model re-emitted the same edit_file call or a turn was retried; (M2) Levenshtein tier 4 was fail-open with no uniqueness guard, silently splicing into the wrong location. Fixes applied at every layer of the pipeline: Matcher (fuzzy-match.ts): raise SIMILARITY_THRESHOLD 0.66 → 0.85; add AMBIGUITY_EPSILON uniqueness guard — two windows within 0.05 of the top score → ambiguous, not a guess; add block-anchor gate (≥3-line needles require first+last line exact match before a window is scored). Edit planner (pending_changes.ts): extract planEdit() as a pure function; idempotency guards detect already-applied states (anchored insert re-stamp, old-gone-but-new-present); findPendingDuplicate() collapses identical pending rows at queue time so M1 never reaches applyOne. Atomic writes (pending_changes.ts): temp-file + rename on the same filesystem so a crash can't leave a half-written source file; realpath() first so symlinks survive the rename. Per-file mutex (pending_changes.ts): withFileLock() serializes concurrent read-modify-write on the same path via a chained-Promise Map. EOL preservation (pending_changes.ts): normalize CRLF → LF for matching, restore native line ending on write so Windows-style files stay clean. Context isolation (inference_context.ts): replace module-level singleton with AsyncLocalStorage so concurrent inference runs (arena parallel dispatch, dispatcher poll racing a user message) each get their own scoped context with no clobbering. Tests: plan-edit.test.ts (pure planEdit unit tests), extended fuzzy-match and pending_changes_integration suites, ALS isolation test that proves overlapping runs get correct session IDs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,52 @@ describe('locateMatch — strategy 4: Levenshtein', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — strategy 4: fail-closed on ambiguity (corruption guard)', () => {
|
||||
it('refuses (ambiguous) when two equally-similar anchored blocks both clear the bar', () => {
|
||||
// The repetitive-file case that duplicated blocks: two blocks share the same
|
||||
// first+last anchor lines and their middle lines are EQUALLY similar to the
|
||||
// (drifted) needle. Tier 4 must refuse rather than splice over one of them.
|
||||
const content = [
|
||||
'const x = {',
|
||||
' total = aa;',
|
||||
'};',
|
||||
'const x = {',
|
||||
' total = bb;',
|
||||
'};',
|
||||
].join('\n');
|
||||
const needle = ['const x = {', ' total = ab;', '};'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('ambiguous');
|
||||
});
|
||||
|
||||
it('refuses a below-threshold near-miss that the old 0.66 floor would have spliced', () => {
|
||||
// ~0.7 similar: under the raised 0.85 floor this is now not_found, so the
|
||||
// caller surfaces a correctable error instead of corrupting the file.
|
||||
const content = 'const grandTotalAmount = a + b;\n';
|
||||
const needle = 'const totalValue = a + b;';
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
|
||||
it('still matches a single genuine high-similarity drift uniquely', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const needle = 'const totals = sum + tax;'; // one-char typo, ~0.96
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('fuzzy');
|
||||
const { start, end } = span(result);
|
||||
expect(content.slice(start, end)).toBe('const total = sum + tax;');
|
||||
});
|
||||
|
||||
it('requires an exact first+last line anchor for multi-line needles', () => {
|
||||
// First line drifted too far to anchor → no window is scored → not_found,
|
||||
// even though the middle lines are identical.
|
||||
const content = ['function compute() {', ' return a + b;', ' return done;', '}'].join('\n');
|
||||
const needle = ['totally different opener', ' return a + b;', '}'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — edge cases', () => {
|
||||
it('returns not_found for an empty needle', () => {
|
||||
expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' });
|
||||
|
||||
@@ -83,6 +83,53 @@ describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () =>
|
||||
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('re-emitted identical edits dedupe at queue and never duplicate on apply', async () => {
|
||||
// Regression: the 2-3x block-stamping corruption. An anchored insert queued
|
||||
// three times (a local model re-emitting the same tool call) must collapse to
|
||||
// ONE pending row and apply exactly once.
|
||||
await queueCreate(sql, testSessionId, null, 'dup.js', '<script>\nrender();\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif"];';
|
||||
const a = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const b = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const c = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
// All three calls return the SAME pending row (deduped).
|
||||
expect(b.id).toBe(a.id);
|
||||
expect(c.id).toBe(a.id);
|
||||
|
||||
await applyOne(sql, a.id, projectRoot);
|
||||
let content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Even a fresh, separately-queued identical edit re-applied is a no-op, not a stamp.
|
||||
const again = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const res = await applyOne(sql, again.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves CRLF line endings on edit', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'crlf.txt', 'line one\r\nline two\r\nline three\r\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'crlf.txt', 'line two', 'line TWO', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
const content = await readFile(resolve(testDir, 'crlf.txt'), 'utf8');
|
||||
expect(content).toBe('line one\r\nline TWO\r\nline three\r\n');
|
||||
});
|
||||
|
||||
it('refuses an edit that matches multiple locations instead of corrupting', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'ambig.js', 'x=1;\ny=2;\nx=1;\n', projectRoot)
|
||||
.then((ch) => applyOne(sql, ch.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'ambig.js', 'x=1;', 'x=9;', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/matches 2 locations/);
|
||||
});
|
||||
|
||||
it('rewindOne → verify reverted', async () => {
|
||||
// Setup: create and apply a file
|
||||
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
|
||||
|
||||
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planEdit } from '../pending_changes.js';
|
||||
|
||||
// planEdit is the pure core of applyOne's edit splice. These tests pin the
|
||||
// idempotency guards that stop the "block stamped 2-3x" corruption: applying the
|
||||
// same queued edit more than once must be a no-op, never a duplicate.
|
||||
|
||||
describe('planEdit — normal application', () => {
|
||||
it('applies a unique exact edit', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'apply', updated: 'a\nbar\nb\n' });
|
||||
});
|
||||
|
||||
it('reports ambiguous when old_string occurs more than once', () => {
|
||||
const content = 'foo\nx\nfoo\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'ambiguous', count: 2 });
|
||||
});
|
||||
|
||||
it('reports not_found when old_string is absent and new is not present', () => {
|
||||
const content = 'alpha\nbeta\n';
|
||||
const plan = planEdit(content, 'gamma that is clearly nowhere', 'delta');
|
||||
expect(plan).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('planEdit — idempotency (the corruption guard)', () => {
|
||||
it('treats a re-applied anchored insert as already-applied (no duplicate)', () => {
|
||||
// The exact mechanism that tripled `const recordFormats` in settings.html:
|
||||
// an anchored insert (old=anchor, new=anchor+block) where the anchor still
|
||||
// matches uniquely after the first apply.
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif","mp4"];';
|
||||
const before = '<script>\nfunction render() {}\n</script>\n';
|
||||
|
||||
const first = planEdit(before, oldStr, newStr);
|
||||
expect(first.kind).toBe('apply');
|
||||
const after = first.kind === 'apply' ? first.updated : '';
|
||||
expect((after.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Re-applying the identical edit to the already-edited content is a no-op.
|
||||
const second = planEdit(after, oldStr, newStr);
|
||||
expect(second).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats an edit whose old_string is gone but new_string is present as already-applied', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const plan = planEdit(content, 'const subtotal = sum;', 'const total = sum + tax;');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats a no-change splice as a noop', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'foo');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'identical' });
|
||||
});
|
||||
|
||||
it('does not duplicate across three repeated applications', () => {
|
||||
const oldStr = 'function f() {';
|
||||
const newStr = 'function f() {\n const x = 1;';
|
||||
let content = 'function f() {\n return x;\n}\n';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const plan = planEdit(content, oldStr, newStr);
|
||||
if (plan.kind === 'apply') content = plan.updated;
|
||||
}
|
||||
expect((content.match(/const x = 1;/g) || []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user