Refine async tool control flow
This commit is contained in:
@@ -65,73 +65,62 @@ export interface BashOperations {
|
|||||||
*/
|
*/
|
||||||
export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations {
|
export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations {
|
||||||
return {
|
return {
|
||||||
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
exec: async (command, cwd, { onData, signal, timeout, env }) => {
|
||||||
return new Promise((resolve, reject) => {
|
const { shell, args } = getShellConfig(options?.shellPath);
|
||||||
void (async () => {
|
try {
|
||||||
const { shell, args } = getShellConfig(options?.shellPath);
|
await fsAccess(cwd, constants.F_OK);
|
||||||
try {
|
} catch {
|
||||||
await fsAccess(cwd, constants.F_OK);
|
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
|
||||||
} catch {
|
}
|
||||||
reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
|
if (signal?.aborted) {
|
||||||
return;
|
throw new Error("aborted");
|
||||||
}
|
}
|
||||||
if (signal?.aborted) {
|
|
||||||
reject(new Error("aborted"));
|
const child = spawn(shell, [...args, command], {
|
||||||
return;
|
cwd,
|
||||||
}
|
detached: process.platform !== "win32",
|
||||||
const child = spawn(shell, [...args, command], {
|
env: env ?? getShellEnv(),
|
||||||
cwd,
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: process.platform !== "win32",
|
windowsHide: true,
|
||||||
env: env ?? getShellEnv(),
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
if (child.pid) trackDetachedChildPid(child.pid);
|
|
||||||
let timedOut = false;
|
|
||||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
||||||
// Set timeout if provided.
|
|
||||||
if (timeout !== undefined && timeout > 0) {
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
if (child.pid) killProcessTree(child.pid);
|
|
||||||
}, timeout * 1000);
|
|
||||||
}
|
|
||||||
// Stream stdout and stderr.
|
|
||||||
child.stdout?.on("data", onData);
|
|
||||||
child.stderr?.on("data", onData);
|
|
||||||
// Handle abort signal by killing the entire process tree.
|
|
||||||
const onAbort = () => {
|
|
||||||
if (child.pid) killProcessTree(child.pid);
|
|
||||||
};
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) onAbort();
|
|
||||||
else signal.addEventListener("abort", onAbort, { once: true });
|
|
||||||
}
|
|
||||||
// Handle shell spawn errors and wait for the process to terminate without hanging
|
|
||||||
// on inherited stdio handles held by detached descendants.
|
|
||||||
waitForChildProcess(child)
|
|
||||||
.then((code) => {
|
|
||||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
||||||
if (signal) signal.removeEventListener("abort", onAbort);
|
|
||||||
if (signal?.aborted) {
|
|
||||||
reject(new Error("aborted"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (timedOut) {
|
|
||||||
reject(new Error(`timeout:${timeout}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve({ exitCode: code });
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (child.pid) untrackDetachedChildPid(child.pid);
|
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
||||||
if (signal) signal.removeEventListener("abort", onAbort);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
})().catch((err: unknown) => reject(err instanceof Error ? err : new Error(String(err))));
|
|
||||||
});
|
});
|
||||||
|
if (child.pid) trackDetachedChildPid(child.pid);
|
||||||
|
let timedOut = false;
|
||||||
|
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||||
|
const onAbort = () => {
|
||||||
|
if (child.pid) killProcessTree(child.pid);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set timeout if provided.
|
||||||
|
if (timeout !== undefined && timeout > 0) {
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
if (child.pid) killProcessTree(child.pid);
|
||||||
|
}, timeout * 1000);
|
||||||
|
}
|
||||||
|
// Stream stdout and stderr.
|
||||||
|
child.stdout?.on("data", onData);
|
||||||
|
child.stderr?.on("data", onData);
|
||||||
|
// Handle abort signal by killing the entire process tree.
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) onAbort();
|
||||||
|
else signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
// Handle shell spawn errors and wait for the process to terminate without hanging
|
||||||
|
// on inherited stdio handles held by detached descendants.
|
||||||
|
const exitCode = await waitForChildProcess(child);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("aborted");
|
||||||
|
}
|
||||||
|
if (timedOut) {
|
||||||
|
throw new Error(`timeout:${timeout}`);
|
||||||
|
}
|
||||||
|
return { exitCode };
|
||||||
|
} finally {
|
||||||
|
if (child.pid) untrackDetachedChildPid(child.pid);
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
if (signal) signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,61 +314,54 @@ export function createEditToolDefinition(
|
|||||||
const absolutePath = resolveToCwd(path, cwd);
|
const absolutePath = resolveToCwd(path, cwd);
|
||||||
|
|
||||||
return withFileMutationQueue(absolutePath, async () => {
|
return withFileMutationQueue(absolutePath, async () => {
|
||||||
let aborted = signal?.aborted ?? false;
|
// Do not reject from an abort event listener here: that would release the
|
||||||
const onAbort = () => {
|
// mutation queue while an in-flight filesystem operation may still finish.
|
||||||
aborted = true;
|
// Checking signal.aborted after each await observes the same aborts while
|
||||||
};
|
// keeping the queue locked until the current operation has settled.
|
||||||
const throwIfAborted = (): void => {
|
const throwIfAborted = (): void => {
|
||||||
if (aborted || signal?.aborted) {
|
if (signal?.aborted) throw new Error("Operation aborted");
|
||||||
throw new Error("Operation aborted");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
throwIfAborted();
|
||||||
|
|
||||||
|
// Check if file exists.
|
||||||
try {
|
try {
|
||||||
|
await ops.access(absolutePath);
|
||||||
|
} catch (error: unknown) {
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
|
const errorMessage =
|
||||||
// Check if file exists.
|
error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
|
||||||
try {
|
throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
|
||||||
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();
|
|
||||||
|
|
||||||
// Read the file.
|
|
||||||
const buffer = await ops.readFile(absolutePath);
|
|
||||||
throwIfAborted();
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
||||||
await ops.writeFile(absolutePath, finalContent);
|
|
||||||
throwIfAborted();
|
|
||||||
|
|
||||||
const diffResult = generateDiffString(baseContent, newContent);
|
|
||||||
const patch = generateUnifiedPatch(path, baseContent, newContent);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
}
|
}
|
||||||
|
throwIfAborted();
|
||||||
|
|
||||||
|
// Read the file.
|
||||||
|
const buffer = await ops.readFile(absolutePath);
|
||||||
|
const rawContent = buffer.toString("utf-8");
|
||||||
|
throwIfAborted();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
throwIfAborted();
|
||||||
|
|
||||||
|
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||||
|
await ops.writeFile(absolutePath, finalContent);
|
||||||
|
throwIfAborted();
|
||||||
|
|
||||||
|
const diffResult = generateDiffString(baseContent, newContent);
|
||||||
|
const patch = generateUnifiedPatch(path, baseContent, newContent);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
renderCall(args, theme, context) {
|
renderCall(args, theme, context) {
|
||||||
|
|||||||
Reference in New Issue
Block a user