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.
54 lines
1.9 KiB
TypeScript
54 lines
1.9 KiB
TypeScript
import { z } from 'zod';
|
|
import type { ToolDef, ToolContext } from './types.js';
|
|
import { applyAll } from '../pending_changes.js';
|
|
|
|
const ApplyPendingInput = z.object({});
|
|
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
|
|
|
|
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
|
name: 'apply_pending',
|
|
description:
|
|
'Apply all pending changes for the current session to disk. ' +
|
|
'Each queued create/edit/delete is executed in order.',
|
|
inputSchema: ApplyPendingInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'apply_pending',
|
|
description:
|
|
'Apply all pending changes for the current session to disk. ' +
|
|
'Each queued create/edit/delete is executed in order.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {},
|
|
required: [],
|
|
},
|
|
},
|
|
},
|
|
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
|
// Under Ask (and Plan) the human approves via the Pending Changes panel — the
|
|
// agent must not auto-apply. Bypass and legacy (undefined) may apply.
|
|
if (context.permissionMode === 'ask' || context.permissionMode === 'plan') {
|
|
return {
|
|
status: 'denied',
|
|
message:
|
|
'Permission mode is Ask — staged changes must be approved by the user in the Pending Changes panel, not applied by the agent.',
|
|
};
|
|
}
|
|
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
|
const succeeded = results.filter((r) => r.success).length;
|
|
const failed = results.filter((r) => !r.success).length;
|
|
|
|
return {
|
|
total: results.length,
|
|
succeeded,
|
|
failed,
|
|
results,
|
|
message:
|
|
results.length === 0
|
|
? 'No pending changes to apply.'
|
|
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
|
|
};
|
|
},
|
|
};
|