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:

  1. Resolves the user‑supplied path to an absolute path
  2. Calls realpathSync.native to 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 queue
  • packages/coding-agent/src/core/tools/edit.ts — Built‑in edit tool uses the queue for all file writes
  • packages/coding-agent/docs/extensions.md — Documentation for custom tool authors
  • packages/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.native ensures 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 edit tool 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →