fix(coding-agent): preserve mutation queue on abort

This commit is contained in:
Armin Ronacher
2026-05-20 11:24:08 +02:00
parent ba33b10392
commit fa656dd06e
3 changed files with 191 additions and 138 deletions
+50 -100
View File
@@ -310,112 +310,62 @@ export function createEditToolDefinition(
const { path, edits } = validateEditInput(input);
const absolutePath = resolveToCwd(path, cwd);
return withFileMutationQueue(
absolutePath,
() =>
new Promise<{
content: Array<{ type: "text"; text: string }>;
details: EditToolDetails | undefined;
}>((resolve, reject) => {
// Check if already aborted.
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
return withFileMutationQueue(absolutePath, async () => {
let aborted = signal?.aborted ?? false;
const onAbort = () => {
aborted = true;
};
const throwIfAborted = (): void => {
if (aborted || signal?.aborted) {
throw new Error("Operation aborted");
}
};
let aborted = false;
signal?.addEventListener("abort", onAbort, { once: true });
try {
throwIfAborted();
// Set up abort handler.
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
// Check if file exists.
try {
await ops.access(absolutePath);
} catch (error: unknown) {
throwIfAborted();
const errorMessage =
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
}
throwIfAborted();
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Read the file.
const buffer = await ops.readFile(absolutePath);
throwIfAborted();
// Perform the edit operation.
void (async () => {
try {
// Check if file exists.
try {
await ops.access(absolutePath);
} catch (error: unknown) {
const errorMessage =
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
if (signal) {
signal.removeEventListener("abort", onAbort);
}
reject(new Error(`Could not edit file: ${path}. ${errorMessage}.`));
return;
}
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const rawContent = buffer.toString("utf-8");
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
throwIfAborted();
// Check if aborted before reading.
if (aborted) {
return;
}
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
await ops.writeFile(absolutePath, finalContent);
throwIfAborted();
// Read the file.
const buffer = await ops.readFile(absolutePath);
const rawContent = buffer.toString("utf-8");
// Check if aborted after reading.
if (aborted) {
return;
}
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent } = applyEditsToNormalizedContent(
normalizedContent,
edits,
path,
);
// Check if aborted before writing.
if (aborted) {
return;
}
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
await ops.writeFile(absolutePath, finalContent);
// Check if aborted after writing.
if (aborted) {
return;
}
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
const diffResult = generateDiffString(baseContent, newContent);
resolve({
content: [
{
type: "text",
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
});
} catch (error: unknown) {
// Clean up abort handler.
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error instanceof Error ? error : new Error(String(error)));
}
}
})();
}),
);
const diffResult = generateDiffString(baseContent, newContent);
return {
content: [
{
type: "text",
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
},
],
details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine },
};
} finally {
signal?.removeEventListener("abort", onAbort);
}
});
},
renderCall(args, theme, context) {
const component = getEditCallRenderComponent(context.state, context.lastComponent);
+30 -38
View File
@@ -200,44 +200,36 @@ export function createWriteToolDefinition(
) {
const absolutePath = resolveToCwd(path, cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(
absolutePath,
() =>
new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>(
(resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
signal?.addEventListener("abort", onAbort, { once: true });
(async () => {
try {
// Create parent directories if needed.
await ops.mkdir(dir);
if (aborted) return;
// Write the file contents.
await ops.writeFile(absolutePath, content);
if (aborted) return;
signal?.removeEventListener("abort", onAbort);
resolve({
content: [
{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` },
],
details: undefined,
});
} catch (error: any) {
signal?.removeEventListener("abort", onAbort);
if (!aborted) reject(error);
}
})();
},
),
);
return withFileMutationQueue(absolutePath, async () => {
let aborted = signal?.aborted ?? false;
const onAbort = () => {
aborted = true;
};
const throwIfAborted = (): void => {
if (aborted || signal?.aborted) {
throw new Error("Operation aborted");
}
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
throwIfAborted();
// Create parent directories if needed.
await ops.mkdir(dir);
throwIfAborted();
// Write the file contents.
await ops.writeFile(absolutePath, content);
throwIfAborted();
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
details: undefined,
};
} finally {
signal?.removeEventListener("abort", onAbort);
}
});
},
renderCall(args, theme, context) {
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;