feat: implement Phase 7a File Lock Manager for concurrent task write coordination

Advisory file-level locking so concurrent tasks coordinate write access safely.

- FileLockManager class with acquire/release/releaseAll operations
- Per-file locks with automatic expiration (configurable timeout, default 2min)
- Re-entrant lock support (same task can re-acquire its own lock)
- Lock conflict detection with detailed conflict info
- Event system for lock lifecycle (acquired/released/expired)
- Reverse index for efficient per-task lock lookup
- Path normalization for consistent lock keys
- 39 comprehensive unit tests covering all operations

Part of Phase 7 (Controlled Write Parallelism) for Issue #12330
This commit is contained in:
Roo Code 2026-05-12 15:21:03 +00:00
parent 35bed0c1b6
commit 9ab2c19d4b
3 changed files with 745 additions and 0 deletions

View file

@ -0,0 +1,348 @@
import path from "path"
/**
* Information about a file lock held by a task.
*/
export interface FileLockInfo {
/** Absolute normalized path of the locked file */
filePath: string
/** ID of the task holding the lock */
taskId: string
/** Timestamp (ms) when the lock was acquired */
acquiredAt: number
}
/**
* Result returned when a lock acquisition attempt fails.
*/
export interface LockConflict {
/** The file that is already locked */
filePath: string
/** The task that currently holds the lock */
holdingTaskId: string
/** How long (ms) the lock has been held */
heldForMs: number
}
/**
* Events emitted by the FileLockManager.
*/
export type FileLockEvent =
| { type: "lock-acquired"; filePath: string; taskId: string }
| { type: "lock-released"; filePath: string; taskId: string }
| { type: "lock-expired"; filePath: string; taskId: string }
| { type: "all-locks-released"; taskId: string; count: number }
export type FileLockEventListener = (event: FileLockEvent) => void
export interface FileLockManagerOptions {
/**
* Maximum duration (ms) a lock can be held before it is forcibly released.
* Default: 120_000 (2 minutes).
*/
lockTimeoutMs?: number
}
const DEFAULT_LOCK_TIMEOUT_MS = 120_000
/**
* Advisory file-level lock manager for coordinating writes across concurrent tasks.
*
* Locks are "advisory" -- they do not use OS-level file locks. Instead, the
* tool execution layer checks the lock manager before allowing write operations.
* This keeps the system portable and testable.
*
* All file paths are normalized to absolute paths using `path.resolve` before
* being used as map keys, ensuring consistent lookup regardless of how the
* path is specified (relative, absolute, trailing slashes, etc.).
*/
export class FileLockManager {
/**
* Map from normalized absolute file path to lock info.
*/
private locks = new Map<string, FileLockInfo>()
/**
* Reverse index: taskId -> set of normalized file paths locked by that task.
*/
private taskLocks = new Map<string, Set<string>>()
/**
* Event listeners.
*/
private listeners: FileLockEventListener[] = []
/**
* Maximum lock hold duration in milliseconds.
*/
private readonly lockTimeoutMs: number
constructor(options?: FileLockManagerOptions) {
this.lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
}
/**
* Attempt to acquire a write lock on a file for a specific task.
*
* If the file is already locked by the same task, refreshes the timestamp
* and returns true (re-entrant). If locked by a different task, checks
* for expiration first -- if the existing lock has expired it is forcibly
* released before granting the new lock.
*
* @returns `true` if the lock was acquired, `false` if another task holds it.
*/
acquireLock(filePath: string, taskId: string): boolean {
const normalized = this.normalizePath(filePath)
const existing = this.locks.get(normalized)
if (existing) {
// Re-entrant: same task already holds the lock -- refresh timestamp
if (existing.taskId === taskId) {
existing.acquiredAt = Date.now()
return true
}
// Check if the existing lock has expired
if (this.isLockExpired(existing)) {
this.forceReleaseLock(normalized, existing.taskId)
} else {
return false
}
}
// Acquire the lock
const lockInfo: FileLockInfo = {
filePath: normalized,
taskId,
acquiredAt: Date.now(),
}
this.locks.set(normalized, lockInfo)
let taskSet = this.taskLocks.get(taskId)
if (!taskSet) {
taskSet = new Set()
this.taskLocks.set(taskId, taskSet)
}
taskSet.add(normalized)
this.emit({ type: "lock-acquired", filePath: normalized, taskId })
return true
}
/**
* Release a lock held by a specific task.
* No-op if the task does not hold the lock.
*/
releaseLock(filePath: string, taskId: string): void {
const normalized = this.normalizePath(filePath)
const existing = this.locks.get(normalized)
if (!existing || existing.taskId !== taskId) {
return
}
this.locks.delete(normalized)
const taskSet = this.taskLocks.get(taskId)
if (taskSet) {
taskSet.delete(normalized)
if (taskSet.size === 0) {
this.taskLocks.delete(taskId)
}
}
this.emit({ type: "lock-released", filePath: normalized, taskId })
}
/**
* Release all locks held by a specific task.
* Called when a task completes, is cancelled, or errors out.
*/
releaseAllLocks(taskId: string): void {
const taskSet = this.taskLocks.get(taskId)
if (!taskSet || taskSet.size === 0) {
this.taskLocks.delete(taskId)
return
}
const count = taskSet.size
for (const normalized of taskSet) {
this.locks.delete(normalized)
}
this.taskLocks.delete(taskId)
this.emit({ type: "all-locks-released", taskId, count })
}
/**
* Check which task (if any) holds the lock on a file.
* Checks for expiration -- if the lock is expired, it is released and
* `undefined` is returned.
*
* @returns The taskId of the lock holder, or `undefined` if unlocked.
*/
getLockHolder(filePath: string): string | undefined {
const normalized = this.normalizePath(filePath)
const existing = this.locks.get(normalized)
if (!existing) {
return undefined
}
if (this.isLockExpired(existing)) {
this.forceReleaseLock(normalized, existing.taskId)
return undefined
}
return existing.taskId
}
/**
* Get detailed lock info for a file, or undefined if not locked.
* Checks for expiration.
*/
getLockInfo(filePath: string): FileLockInfo | undefined {
const normalized = this.normalizePath(filePath)
const existing = this.locks.get(normalized)
if (!existing) {
return undefined
}
if (this.isLockExpired(existing)) {
this.forceReleaseLock(normalized, existing.taskId)
return undefined
}
return { ...existing }
}
/**
* Get the conflict details when a lock acquisition would fail.
* Returns undefined if the file is not locked by another task.
*/
getLockConflict(filePath: string, taskId: string): LockConflict | undefined {
const normalized = this.normalizePath(filePath)
const existing = this.locks.get(normalized)
if (!existing || existing.taskId === taskId) {
return undefined
}
if (this.isLockExpired(existing)) {
this.forceReleaseLock(normalized, existing.taskId)
return undefined
}
return {
filePath: normalized,
holdingTaskId: existing.taskId,
heldForMs: Date.now() - existing.acquiredAt,
}
}
/**
* List all files currently locked by a specific task.
*/
getLockedFiles(taskId: string): string[] {
const taskSet = this.taskLocks.get(taskId)
if (!taskSet) {
return []
}
return Array.from(taskSet)
}
/**
* Get all currently held locks. Primarily for debugging/UI display.
* Expired locks are cleaned up during this call.
*/
getAllLocks(): FileLockInfo[] {
const result: FileLockInfo[] = []
const expired: Array<{ normalized: string; taskId: string }> = []
for (const [normalized, info] of this.locks) {
if (this.isLockExpired(info)) {
expired.push({ normalized, taskId: info.taskId })
} else {
result.push({ ...info })
}
}
// Clean up expired locks
for (const { normalized, taskId } of expired) {
this.forceReleaseLock(normalized, taskId)
}
return result
}
/**
* Get the total number of active locks.
*/
get lockCount(): number {
return this.locks.size
}
/**
* Register an event listener.
*/
onEvent(listener: FileLockEventListener): void {
this.listeners.push(listener)
}
/**
* Remove an event listener.
*/
offEvent(listener: FileLockEventListener): void {
const idx = this.listeners.indexOf(listener)
if (idx !== -1) {
this.listeners.splice(idx, 1)
}
}
/**
* Clear all locks and listeners. Primarily for testing.
*/
dispose(): void {
this.locks.clear()
this.taskLocks.clear()
this.listeners = []
}
// --- Private helpers ---
private normalizePath(filePath: string): string {
return path.resolve(filePath)
}
private isLockExpired(info: FileLockInfo): boolean {
return Date.now() - info.acquiredAt > this.lockTimeoutMs
}
private forceReleaseLock(normalized: string, taskId: string): void {
this.locks.delete(normalized)
const taskSet = this.taskLocks.get(taskId)
if (taskSet) {
taskSet.delete(normalized)
if (taskSet.size === 0) {
this.taskLocks.delete(taskId)
}
}
this.emit({ type: "lock-expired", filePath: normalized, taskId })
}
private emit(event: FileLockEvent): void {
for (const listener of this.listeners) {
try {
listener(event)
} catch {
// Swallow listener errors to avoid breaking lock operations
}
}
}
}

View file

@ -0,0 +1,389 @@
import path from "path"
import { FileLockManager, type FileLockEvent } from "../FileLockManager"
describe("FileLockManager", () => {
let manager: FileLockManager
beforeEach(() => {
manager = new FileLockManager()
})
afterEach(() => {
manager.dispose()
})
describe("acquireLock", () => {
it("should acquire a lock on an unlocked file", () => {
const result = manager.acquireLock("/project/foo.ts", "task-1")
expect(result).toBe(true)
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
})
it("should allow the same task to re-acquire a lock (re-entrant)", () => {
manager.acquireLock("/project/foo.ts", "task-1")
const result = manager.acquireLock("/project/foo.ts", "task-1")
expect(result).toBe(true)
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
})
it("should deny a lock when another task holds it", () => {
manager.acquireLock("/project/foo.ts", "task-1")
const result = manager.acquireLock("/project/foo.ts", "task-2")
expect(result).toBe(false)
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
})
it("should normalize paths so equivalent paths resolve to the same lock", () => {
manager.acquireLock("/project/src/../foo.ts", "task-1")
const result = manager.acquireLock("/project/foo.ts", "task-2")
expect(result).toBe(false)
})
it("should allow locking multiple different files by the same task", () => {
expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true)
expect(manager.acquireLock("/project/b.ts", "task-1")).toBe(true)
expect(manager.getLockedFiles("task-1")).toHaveLength(2)
})
it("should allow different tasks to lock different files", () => {
expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true)
expect(manager.acquireLock("/project/b.ts", "task-2")).toBe(true)
expect(manager.getLockHolder("/project/a.ts")).toBe("task-1")
expect(manager.getLockHolder("/project/b.ts")).toBe("task-2")
})
})
describe("releaseLock", () => {
it("should release a lock held by the specified task", () => {
manager.acquireLock("/project/foo.ts", "task-1")
manager.releaseLock("/project/foo.ts", "task-1")
expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined()
})
it("should be a no-op when the task does not hold the lock", () => {
manager.acquireLock("/project/foo.ts", "task-1")
manager.releaseLock("/project/foo.ts", "task-2")
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
})
it("should be a no-op when the file is not locked", () => {
// Should not throw
manager.releaseLock("/project/foo.ts", "task-1")
})
it("should allow another task to acquire the lock after release", () => {
manager.acquireLock("/project/foo.ts", "task-1")
manager.releaseLock("/project/foo.ts", "task-1")
expect(manager.acquireLock("/project/foo.ts", "task-2")).toBe(true)
})
it("should clean up taskLocks when the last lock for a task is released", () => {
manager.acquireLock("/project/foo.ts", "task-1")
manager.releaseLock("/project/foo.ts", "task-1")
expect(manager.getLockedFiles("task-1")).toHaveLength(0)
})
})
describe("releaseAllLocks", () => {
it("should release all locks for a task", () => {
manager.acquireLock("/project/a.ts", "task-1")
manager.acquireLock("/project/b.ts", "task-1")
manager.acquireLock("/project/c.ts", "task-1")
manager.releaseAllLocks("task-1")
expect(manager.getLockedFiles("task-1")).toHaveLength(0)
expect(manager.getLockHolder("/project/a.ts")).toBeUndefined()
expect(manager.getLockHolder("/project/b.ts")).toBeUndefined()
expect(manager.getLockHolder("/project/c.ts")).toBeUndefined()
})
it("should not affect locks held by other tasks", () => {
manager.acquireLock("/project/a.ts", "task-1")
manager.acquireLock("/project/b.ts", "task-2")
manager.releaseAllLocks("task-1")
expect(manager.getLockHolder("/project/b.ts")).toBe("task-2")
})
it("should be a no-op when the task has no locks", () => {
// Should not throw
manager.releaseAllLocks("task-nonexistent")
})
})
describe("getLockHolder", () => {
it("should return undefined for unlocked files", () => {
expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined()
})
it("should return the task ID for locked files", () => {
manager.acquireLock("/project/foo.ts", "task-1")
expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1")
})
})
describe("getLockInfo", () => {
it("should return undefined for unlocked files", () => {
expect(manager.getLockInfo("/project/foo.ts")).toBeUndefined()
})
it("should return a copy of the lock info", () => {
manager.acquireLock("/project/foo.ts", "task-1")
const info = manager.getLockInfo("/project/foo.ts")
expect(info).toBeDefined()
expect(info!.taskId).toBe("task-1")
expect(info!.filePath).toBe(path.resolve("/project/foo.ts"))
expect(info!.acquiredAt).toBeGreaterThan(0)
})
})
describe("getLockConflict", () => {
it("should return undefined when file is not locked", () => {
expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined()
})
it("should return undefined when the requesting task holds the lock", () => {
manager.acquireLock("/project/foo.ts", "task-1")
expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined()
})
it("should return conflict details when another task holds the lock", () => {
manager.acquireLock("/project/foo.ts", "task-1")
const conflict = manager.getLockConflict("/project/foo.ts", "task-2")
expect(conflict).toBeDefined()
expect(conflict!.holdingTaskId).toBe("task-1")
expect(conflict!.filePath).toBe(path.resolve("/project/foo.ts"))
expect(conflict!.heldForMs).toBeGreaterThanOrEqual(0)
})
})
describe("getLockedFiles", () => {
it("should return empty array for a task with no locks", () => {
expect(manager.getLockedFiles("task-1")).toEqual([])
})
it("should return all files locked by a task", () => {
manager.acquireLock("/project/a.ts", "task-1")
manager.acquireLock("/project/b.ts", "task-1")
const files = manager.getLockedFiles("task-1")
expect(files).toHaveLength(2)
expect(files).toContain(path.resolve("/project/a.ts"))
expect(files).toContain(path.resolve("/project/b.ts"))
})
})
describe("getAllLocks", () => {
it("should return empty array when no locks exist", () => {
expect(manager.getAllLocks()).toEqual([])
})
it("should return all active locks", () => {
manager.acquireLock("/project/a.ts", "task-1")
manager.acquireLock("/project/b.ts", "task-2")
const allLocks = manager.getAllLocks()
expect(allLocks).toHaveLength(2)
})
})
describe("lockCount", () => {
it("should return 0 initially", () => {
expect(manager.lockCount).toBe(0)
})
it("should track the number of active locks", () => {
manager.acquireLock("/project/a.ts", "task-1")
expect(manager.lockCount).toBe(1)
manager.acquireLock("/project/b.ts", "task-1")
expect(manager.lockCount).toBe(2)
manager.releaseLock("/project/a.ts", "task-1")
expect(manager.lockCount).toBe(1)
})
})
describe("lock expiration", () => {
it("should auto-expire locks after timeout and allow reacquisition", () => {
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-1")
// Simulate time passing by directly manipulating the lock's acquiredAt
const locks = (shortTimeoutManager as any).locks as Map<string, any>
const normalized = path.resolve("/project/foo.ts")
const lockInfo = locks.get(normalized)
lockInfo.acquiredAt = Date.now() - 100 // 100ms ago, > 50ms timeout
// The lock should now be considered expired
expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBeUndefined()
expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true)
shortTimeoutManager.dispose()
})
it("should auto-expire locks during acquireLock by another task", () => {
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
// Simulate expiry
const locks = (shortTimeoutManager as any).locks as Map<string, any>
const normalized = path.resolve("/project/foo.ts")
locks.get(normalized).acquiredAt = Date.now() - 100
// Another task should be able to acquire the expired lock
expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true)
expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-2")
shortTimeoutManager.dispose()
})
it("should clean up expired locks during getAllLocks", () => {
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
shortTimeoutManager.acquireLock("/project/a.ts", "task-1")
shortTimeoutManager.acquireLock("/project/b.ts", "task-2")
// Expire only task-1's lock
const locks = (shortTimeoutManager as any).locks as Map<string, any>
locks.get(path.resolve("/project/a.ts")).acquiredAt = Date.now() - 100
const allLocks = shortTimeoutManager.getAllLocks()
expect(allLocks).toHaveLength(1)
expect(allLocks[0].taskId).toBe("task-2")
shortTimeoutManager.dispose()
})
it("should return undefined from getLockConflict for expired locks", () => {
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
// Expire the lock
const locks = (shortTimeoutManager as any).locks as Map<string, any>
locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100
expect(shortTimeoutManager.getLockConflict("/project/foo.ts", "task-2")).toBeUndefined()
shortTimeoutManager.dispose()
})
})
describe("events", () => {
it("should emit lock-acquired events", () => {
const events: FileLockEvent[] = []
manager.onEvent((e) => events.push(e))
manager.acquireLock("/project/foo.ts", "task-1")
expect(events).toHaveLength(1)
expect(events[0].type).toBe("lock-acquired")
expect(events[0].taskId).toBe("task-1")
})
it("should emit lock-released events", () => {
const events: FileLockEvent[] = []
manager.acquireLock("/project/foo.ts", "task-1")
manager.onEvent((e) => events.push(e))
manager.releaseLock("/project/foo.ts", "task-1")
expect(events).toHaveLength(1)
expect(events[0].type).toBe("lock-released")
})
it("should emit all-locks-released events", () => {
const events: FileLockEvent[] = []
manager.acquireLock("/project/a.ts", "task-1")
manager.acquireLock("/project/b.ts", "task-1")
manager.onEvent((e) => events.push(e))
manager.releaseAllLocks("task-1")
expect(events).toHaveLength(1)
expect(events[0].type).toBe("all-locks-released")
if (events[0].type === "all-locks-released") {
expect(events[0].count).toBe(2)
}
})
it("should emit lock-expired events when a lock times out", () => {
const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 })
const events: FileLockEvent[] = []
shortTimeoutManager.onEvent((e) => events.push(e))
shortTimeoutManager.acquireLock("/project/foo.ts", "task-1")
// Expire the lock
const locks = (shortTimeoutManager as any).locks as Map<string, any>
locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100
// Trigger expiration check via getLockHolder
shortTimeoutManager.getLockHolder("/project/foo.ts")
const expiredEvents = events.filter((e) => e.type === "lock-expired")
expect(expiredEvents).toHaveLength(1)
shortTimeoutManager.dispose()
})
it("should allow removing event listeners", () => {
const events: FileLockEvent[] = []
const listener = (e: FileLockEvent) => events.push(e)
manager.onEvent(listener)
manager.acquireLock("/project/foo.ts", "task-1")
expect(events).toHaveLength(1)
manager.offEvent(listener)
manager.acquireLock("/project/bar.ts", "task-1")
expect(events).toHaveLength(1) // No new events
})
it("should not throw if a listener throws", () => {
manager.onEvent(() => {
throw new Error("listener error")
})
// Should not throw
expect(() => manager.acquireLock("/project/foo.ts", "task-1")).not.toThrow()
})
})
describe("dispose", () => {
it("should clear all locks and listeners", () => {
const events: FileLockEvent[] = []
manager.onEvent((e) => events.push(e))
manager.acquireLock("/project/a.ts", "task-1")
manager.acquireLock("/project/b.ts", "task-2")
manager.dispose()
expect(manager.lockCount).toBe(0)
expect(manager.getAllLocks()).toEqual([])
// Listener should have been removed
manager.acquireLock("/project/c.ts", "task-3")
expect(events).toHaveLength(2) // Only the pre-dispose events
})
})
describe("re-entrant lock refresh", () => {
it("should refresh the acquiredAt timestamp on re-entrant lock", () => {
manager.acquireLock("/project/foo.ts", "task-1")
const info1 = manager.getLockInfo("/project/foo.ts")
// Small delay to ensure timestamp differs
const originalTime = info1!.acquiredAt
// Manipulate time to verify refresh
const locks = (manager as any).locks as Map<string, any>
const normalized = path.resolve("/project/foo.ts")
locks.get(normalized).acquiredAt = originalTime - 1000
manager.acquireLock("/project/foo.ts", "task-1")
const info2 = manager.getLockInfo("/project/foo.ts")
expect(info2!.acquiredAt).toBeGreaterThan(originalTime - 1000)
})
})
})

View file

@ -0,0 +1,8 @@
export {
FileLockManager,
type FileLockManagerOptions,
type FileLockInfo,
type LockConflict,
type FileLockEvent,
type FileLockEventListener,
} from "./FileLockManager"