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:
53
apps/coder/src/services/tools/write-gate.ts
Normal file
53
apps/coder/src/services/tools/write-gate.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Permission-gate helpers for native BooCode write tools. The gate comes from
|
||||
* the per-run inference context (`ToolContext.permissionMode`):
|
||||
* plan — deny the write (read-only); nothing is staged.
|
||||
* bypass — apply the staged change immediately (no queue, no approval).
|
||||
* ask / undefined — leave it in the pending-changes queue for review.
|
||||
*/
|
||||
import type { ToolContext } from './types.js';
|
||||
import { applyOne } from '../pending_changes.js';
|
||||
|
||||
/** Result returned when a write is denied under Plan (read-only) mode. */
|
||||
export function denyReadOnly(operation: string): unknown {
|
||||
return {
|
||||
status: 'denied',
|
||||
operation,
|
||||
message: `Read-only (Plan) permission mode — ${operation} is not permitted. Switch to Ask or Bypass to make changes.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Finalize a just-staged change per the permission gate: apply now under Bypass,
|
||||
* otherwise return it as queued for the human to approve. */
|
||||
export async function finalizeWrite(
|
||||
context: ToolContext,
|
||||
projectRoot: string,
|
||||
change: { id: string; file_path: string; operation: string },
|
||||
queuedHint: string,
|
||||
): Promise<unknown> {
|
||||
if (context.permissionMode === 'bypass') {
|
||||
const res = await applyOne(context.sql, change.id, projectRoot);
|
||||
console.log(
|
||||
`[write-gate] bypass apply ${change.operation} ${change.file_path} -> ${res.success ? 'applied' : 'FAILED: ' + (res.error ?? '?')}`,
|
||||
);
|
||||
return {
|
||||
status: res.success ? 'applied' : 'failed',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: res.success
|
||||
? `${change.operation} applied to ${change.file_path}.`
|
||||
: `Apply failed for ${change.file_path}: ${res.error ?? 'unknown error'}. Left in the pending queue.`,
|
||||
};
|
||||
}
|
||||
console.log(
|
||||
`[write-gate] ${context.permissionMode ?? 'legacy'} queued ${change.operation} ${change.file_path}`,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: queuedHint,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user