Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ export interface ExtensionContext {
export const DEFAULT_MAX_BYTES: number;
export const DEFAULT_MAX_LINES: number;
export function truncateHead(text: string, options: { maxBytes?: number; maxLines?: number }): { content: string };
export function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T>;
export function convertToLlm(branch: unknown[]): any[];
export function serializeConversation(messages: any[]): string;
export function createBashToolDefinition(
Expand Down
35 changes: 34 additions & 1 deletion src/batch/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { logWarn } from "../config/log.js";
import { execFile } from "node:child_process";
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import {
type FileOpInput,
type RgOpInput,
Expand Down Expand Up @@ -42,6 +41,40 @@ import { formatDirectoryListing } from "./format-directory-listing.js";
import { applyPatch } from "./apply-patch.js";
import { buildFileContextMap } from "./symbols.js";

// ---------------------------------------------------------------------------
// Per-file mutation queue
// ---------------------------------------------------------------------------

const fileMutationQueues = new Map<string, Promise<unknown>>();

/**
* Queue file mutations by path so concurrent operations on the same file
* are serialized. This replaces the host-provided helper that older OMP
* builds do not re-export, avoiding extension-validation failures on
* install.
*/
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const previous = fileMutationQueues.get(filePath);
const next = (async () => {
if (previous) {
try {
await previous;
} catch {
// Continue regardless of prior failure.
}
}
return fn();
})();
fileMutationQueues.set(filePath, next);
try {
return await next;
} finally {
if (fileMutationQueues.get(filePath) === next) {
fileMutationQueues.delete(filePath);
}
}
}

// ---------------------------------------------------------------------------
// Read helpers
// ---------------------------------------------------------------------------
Expand Down
113 changes: 113 additions & 0 deletions tests/mutation-queue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { withFileMutationQueue } from "../src/batch/execute.js";

interface Gate<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: unknown): void;
}

function gate<T>(): Gate<T> {
const { promise, resolve, reject } = Promise.withResolvers<T>();
return { promise, resolve, reject };
}

describe("withFileMutationQueue", () => {
it("runs a single operation immediately", async () => {
const result = await withFileMutationQueue("/tmp/a.txt", async () => "done");
expect(result).toBe("done");
});

it("serializes concurrent operations on the same path", async () => {
const events: string[] = [];
const first = gate<void>();
const second = gate<void>();
const firstStarted = gate<void>();
const secondStarted = gate<void>();

const p1 = withFileMutationQueue("/tmp/shared.txt", async () => {
events.push("first-start");
firstStarted.resolve();
await first.promise;
events.push("first-end");
return 1;
});

const p2 = withFileMutationQueue("/tmp/shared.txt", async () => {
events.push("second-start");
secondStarted.resolve();
await second.promise;
events.push("second-end");
return 2;
});

// Wait until the first operation has definitely started.
await firstStarted.promise;
expect(events).toEqual(["first-start"]);

// Release the first operation and wait for the second to start.
first.resolve();
await secondStarted.promise;
expect(events).toEqual(["first-start", "first-end", "second-start"]);

second.resolve();
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toBe(1);
expect(r2).toBe(2);
expect(events).toEqual(["first-start", "first-end", "second-start", "second-end"]);
});

it("does not block operations on different paths", async () => {
const events: string[] = [];
const a = gate<void>();
const b = gate<void>();

const pa = withFileMutationQueue("/tmp/a.txt", async () => {
events.push("a-start");
await a.promise;
events.push("a-end");
});

const pb = withFileMutationQueue("/tmp/b.txt", async () => {
events.push("b-start");
await b.promise;
events.push("b-end");
});

await Promise.resolve();

// Different paths should execute concurrently.
expect(events).toContain("a-start");
expect(events).toContain("b-start");
expect(events.length).toBe(2);

a.resolve();
b.resolve();
await Promise.all([pa, pb]);
});

it("continues the queue after a failed operation", async () => {
const events: string[] = [];
const first = gate<void>();

const p1 = withFileMutationQueue("/tmp/fail.txt", async () => {
events.push("fail-start");
await first.promise;
throw new Error("intentional failure");
});

const p2 = withFileMutationQueue("/tmp/fail.txt", async () => {
events.push("after-fail-start");
return "recovered";
});

await Promise.resolve();
expect(events).toEqual(["fail-start"]);

first.resolve();

await expect(p1).rejects.toThrow("intentional failure");
await expect(p2).resolves.toBe("recovered");
expect(events).toEqual(["fail-start", "after-fail-start"]);
});
});
Loading