What Is the File Mutation Queue in Prime‑Agent and How Does It Handle Concurrent Edits?
Prime‑Agent's file mutation queue is a lightweight coordination primitive that guarantees serial execution of all mutations targeting the same filesystem path while allowing mutations to different files to run in parallel.
The file mutation queue is essential infrastructure in the Prime‑Agent coding agent. It prevents race conditions when multiple tools or async operations attempt to read and write the same file simultaneously. This article explains the queue's implementation in packages/coding-agent/src/core/tools/file‑mutation‑queue.ts and how it achieves both safety and performance.
How the File Mutation Queue Works
The queue uses a promise‑chain pattern rather than heavy locks or mutexes. This design keeps overhead minimal while ensuring strict ordering per file.
Canonicalizing the Target Path
Before any queue operation, the path is normalized via getMutationQueueKey(). This function:
- Resolves the user‑supplied path to an absolute path
- Calls
realpathSync.nativeto dereference symlinks
The resulting canonical string becomes the queue key. This ensures that /tmp/file.txt and /tmp/link.txt (pointing to the same inode) share one logical queue, preventing split‑brain scenarios.
// From packages/coding-agent/src/core/tools/file-mutation-queue.ts
// Lines 6-12 (conceptual)
function getMutationQueueKey(rawPath: string): string {
const absolute = path.resolve(rawPath);
try {
return fs.realpathSync.native(absolute);
} catch {
return absolute; // Fallback if path doesn't exist yet
}
}
Per‑Key Promise Chain Storage
A Map<string, Promise<void>> named fileMutationQueues stores the tail promise of each file's active chain. When no operations are pending for a file, no entry exists in the map.
// Lines 4-5 (conceptual)
const fileMutationQueues = new Map<string, Promise<void>>();
Appending New Slots to the Chain
When withFileMutationQueue() is invoked, the current tail is retrieved (or a resolved promise if the file has no pending operations). A new deferred promise is created and chained:
// Lines 23-28 (conceptual)
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
const nextQueue = new Promise<void>((resolve) => { releaseNext = resolve; });
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
The chainedQueue becomes the new tail. Critically, nextQueue does not resolve until releaseNext is called—this happens after the user's mutation function completes.
Executing the Mutation
The mutation runs only after all prior operations on that file have finished:
// Lines 30-33 (conceptual)
await currentQueue; // Wait for previous mutations
try {
return await fn(); // Run the supplied mutation function
} finally {
releaseNext(); // Unblock the next queued operation
}
Cleanup and Memory Management
After the mutation completes (success or failure), the queue cleans up:
// Lines 34-38 (conceptual)
// If this slot is still the tail, remove it to prevent memory leaks
if (fileMutationQueues.get(key) === chainedQueue) {
fileMutationQueues.delete(key);
}
This lazy cleanup ensures the map stays bounded even under high churn.
File Mutation Queue vs. Unprotected Writes
| Aspect | Unprotected fs.writeFile |
With File Mutation Queue |
|---|---|---|
| Same‑file concurrency | Race conditions, data loss | Strict serialization |
| Cross‑file concurrency | Parallel (but unsafe) | Parallel and safe |
| Memory overhead | None | One map entry per active file |
| Ordering guarantee | None | FIFO per file |
Practical Code Examples
Serializing Two Edits to the Same File
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import * as fs from "fs";
await Promise.all([
withFileMutationQueue("/tmp/shared.txt", async () => {
await fs.promises.appendFile("/tmp/shared.txt", "first\n");
}),
withFileMutationQueue("/tmp/shared.txt", async () => {
await fs.promises.appendFile("/tmp/shared.txt", "second\n");
})
]);
// Result: "first\nsecond\n" or "second\nfirst\n" depending on call order
// Never: truncated or interleaved content
Despite Promise.all, the second withFileMutationQueue call blocks until the first releases its slot. The file mutation queue enforces this ordering transparently.
Parallel Edits to Different Files
await Promise.all([
withFileMutationQueue("/tmp/a.txt", async () => {
await fs.promises.writeFile("/tmp/a.txt", "A");
}),
withFileMutationQueue("/tmp/b.txt", async () => {
await fs.promises.writeFile("/tmp/b.txt", "B");
})
]);
// Both writes execute simultaneously—different queue keys, no blocking
Using the Queue in Custom Tools
Custom tools that mutate files should wrap their filesystem operations:
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import * as path from "path";
import * as fs from "fs";
async function myToolEdit(ctx: ToolContext, targetPath: string, newContent: string) {
const absolutePath = path.resolve(ctx.cwd, targetPath);
return withFileMutationQueue(absolutePath, async () => {
await fs.promises.writeFile(absolutePath, newContent);
return { ok: true, bytesWritten: newContent.length };
});
}
As documented in packages/coding-agent/docs/extensions.md, this pattern is required for any custom tool performing file mutations to maintain Prime‑Agent's consistency guarantees.
Key Source Files
packages/coding-agent/src/core/tools/file‑mutation‑queue.ts— Core implementation of the per‑file promise queuepackages/coding-agent/src/core/tools/edit.ts— Built‑inedittool uses the queue for all file writespackages/coding-agent/docs/extensions.md— Documentation for custom tool authorspackages/coding-agent/test/file‑mutation‑queue.test.ts— Test coverage for serialization and parallelism
Summary
- The file mutation queue in Prime‑Agent serializes all operations targeting the same filesystem path using a lightweight promise‑chain pattern
- Canonical path resolution via
realpathSync.nativeensures symlinks don't create duplicate queues - Per‑file parallel execution is preserved—operations on different files never block each other
- Automatic cleanup removes idle queue entries to prevent memory leaks
- The built‑in
edittool and documented custom tool patterns rely on this queue for deterministic, race‑free file mutations
Frequently Asked Questions
What happens if two tools try to edit the same file simultaneously?
Prime‑Agent's file mutation queue orders them sequentially. The second tool's withFileMutationQueue call chains behind the first and starts only after the first mutation completes. This prevents read‑modify‑write races where both tools might base their edits on stale file contents.
Does the file mutation queue block all file operations?
No. The queue blocks only operations targeting the same canonical path. Mutations to different files proceed in parallel with no overhead. This design maximizes throughput while guaranteeing safety.
How does the queue handle files that don't exist yet?
getMutationQueueKey() falls back to the absolute path if realpathSync.native throws (typically because the file doesn't exist). Once the file is created, subsequent calls canonicalize to the same key. The queue remains correct because all operations for that logical file share one chain regardless of when the file appears.
Is the file mutation queue required for custom tools?
According to packages/coding-agent/docs/extensions.md, any custom tool that performs file mutations must use withFileMutationQueue. Omitting it risks data corruption when the tool runs concurrently with Prime‑Agent's built‑in file operations or other custom tools.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →