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>
50 lines
1.9 KiB
TypeScript
50 lines
1.9 KiB
TypeScript
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
import type { Sql } from '../../db.js';
|
|
import type { PermissionMode } from './types.js';
|
|
|
|
/**
|
|
* Per-run inference context for write tools.
|
|
*
|
|
* Write tools need ambient state (sql, sessionId, the permission gate) that the
|
|
* BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
|
|
* carry. This used to be a single module-level `let current` — but the inference
|
|
* runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
|
|
* message racing a dispatcher-polled native task; two chat tabs streaming) would
|
|
* clobber each other's context, and `cancel()` cleared it for ALL in-flight runs.
|
|
*
|
|
* AsyncLocalStorage gives each run its own context: `enqueue()` starts its async
|
|
* loop synchronously inside `runWithInferenceContext`, so the store propagates
|
|
* through every awaited tool execution in that run — and only that run.
|
|
*/
|
|
|
|
export interface InferenceContext {
|
|
sql: Sql;
|
|
sessionId: string;
|
|
taskId: string | null;
|
|
/** Native-BooCode permission gate, set per run from the request/task mode. */
|
|
permissionMode?: PermissionMode;
|
|
}
|
|
|
|
const storage = new AsyncLocalStorage<InferenceContext>();
|
|
|
|
/**
|
|
* Bind `ctx` for the duration of the (possibly detached) async chain `fn` starts.
|
|
* The inference runner kicks off its loop synchronously within this call, so all
|
|
* downstream `await`s — including write-tool `execute` via the adapter — read the
|
|
* same store. Concurrent runs each get their own; nothing is shared or cleared
|
|
* out from under an in-flight run.
|
|
*/
|
|
export function runWithInferenceContext<T>(ctx: InferenceContext, fn: () => T): T {
|
|
return storage.run(ctx, fn);
|
|
}
|
|
|
|
export function getInferenceContext(): InferenceContext {
|
|
const ctx = storage.getStore();
|
|
if (!ctx) {
|
|
throw new Error(
|
|
'Write tool called outside inference context — runWithInferenceContext() did not wrap this run',
|
|
);
|
|
}
|
|
return ctx;
|
|
}
|