Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 41623ea

Browse files
committed
feat: add FileLockManager for parallel agent file coordination (Phase 7a)
Implements a readers-writer lock manager to coordinate file access across concurrent tasks/agents. This is the foundational service for Phase 7 parallel agent execution (Issue #12330). - FileLockManager with read/write lock semantics - Concurrent readers, exclusive writers - FIFO queue with writer priority and timeout support - Auto-release on task disposal via releaseAllForTask() - Comprehensive test suite (24 tests) - Wired into Task.dispose() for automatic cleanup
1 parent 28acb6a commit 41623ea

5 files changed

Lines changed: 957 additions & 0 deletions

File tree

src/core/task/Task.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import pWaitFor from "p-wait-for"
1515
import { serializeError } from "serialize-error"
1616
import { Package } from "../../shared/package"
1717
import { formatToolInvocation } from "../tools/helpers/toolResultFormatting"
18+
import { fileLockManager } from "../../services/file-lock"
1819

1920
import {
2021
type TaskLike,
@@ -2293,6 +2294,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
22932294
console.error("Error releasing terminals:", error)
22942295
}
22952296

2297+
// Release any file locks held by this task
2298+
try {
2299+
fileLockManager.releaseAllForTask(this.taskId)
2300+
} catch (error) {
2301+
console.error("Error releasing file locks:", error)
2302+
}
2303+
22962304
// Cleanup command output artifacts
22972305
getTaskDirectoryPath(this.globalStoragePath, this.taskId)
22982306
.then((taskDir) => {
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import * as path from "path"
2+
3+
import type { LockType, LockRequest, LockHandle, FileLockState, PendingLock } from "./types"
4+
5+
const DEFAULT_TIMEOUT_MS = 30_000
6+
7+
/**
8+
* FileLockManager coordinates file access across concurrent tasks/agents.
9+
*
10+
* It implements a readers-writer lock per file path:
11+
* - Multiple tasks can hold read locks on the same file concurrently.
12+
* - Only one task can hold a write lock on a file at a time.
13+
* - A write lock blocks all other readers and writers.
14+
* - Pending requests are queued FIFO with writer priority.
15+
*
16+
* This manager operates at the logical/agent level, above the OS-level
17+
* advisory locking provided by `proper-lockfile` in `safeWriteJson`.
18+
* It coordinates between in-process tasks, not between OS processes.
19+
*/
20+
export class FileLockManager {
21+
private locks: Map<string, FileLockState> = new Map()
22+
23+
/**
24+
* Normalize a file path to an absolute, OS-consistent key.
25+
*/
26+
private normalizePath(filePath: string): string {
27+
return path.resolve(filePath)
28+
}
29+
30+
/**
31+
* Get or create the lock state for a file.
32+
*/
33+
private getOrCreateState(normalizedPath: string): FileLockState {
34+
let state = this.locks.get(normalizedPath)
35+
36+
if (!state) {
37+
state = {
38+
readers: new Set(),
39+
writer: null,
40+
queue: [],
41+
}
42+
this.locks.set(normalizedPath, state)
43+
}
44+
45+
return state
46+
}
47+
48+
/**
49+
* Clean up empty lock state entries to prevent memory leaks.
50+
*/
51+
private cleanupIfEmpty(normalizedPath: string): void {
52+
const state = this.locks.get(normalizedPath)
53+
54+
if (state && state.readers.size === 0 && state.writer === null && state.queue.length === 0) {
55+
this.locks.delete(normalizedPath)
56+
}
57+
}
58+
59+
/**
60+
* Create a LockHandle for a granted lock.
61+
*/
62+
private createHandle(taskId: string, normalizedPath: string, lockType: LockType): LockHandle {
63+
let released = false
64+
65+
return {
66+
taskId,
67+
filePath: normalizedPath,
68+
lockType,
69+
acquiredAt: Date.now(),
70+
release: () => {
71+
if (released) {
72+
return // Idempotent
73+
}
74+
75+
released = true
76+
this.releaseLock(normalizedPath, taskId, lockType)
77+
},
78+
}
79+
}
80+
81+
/**
82+
* Release a specific lock and process the queue.
83+
*/
84+
private releaseLock(normalizedPath: string, taskId: string, lockType: LockType): void {
85+
const state = this.locks.get(normalizedPath)
86+
87+
if (!state) {
88+
return
89+
}
90+
91+
if (lockType === "read") {
92+
state.readers.delete(taskId)
93+
} else if (lockType === "write" && state.writer === taskId) {
94+
state.writer = null
95+
}
96+
97+
this.processQueue(normalizedPath)
98+
this.cleanupIfEmpty(normalizedPath)
99+
}
100+
101+
/**
102+
* Try to grant the next pending lock(s) in the queue.
103+
*
104+
* Writer priority: if the next item in the queue is a write request,
105+
* it will be granted before any subsequent read requests (once current
106+
* readers/writer finish). This prevents writer starvation.
107+
*/
108+
private processQueue(normalizedPath: string): void {
109+
const state = this.locks.get(normalizedPath)
110+
111+
if (!state || state.queue.length === 0) {
112+
return
113+
}
114+
115+
// Process as many requests as we can from the front of the queue
116+
while (state.queue.length > 0) {
117+
const next = state.queue[0]
118+
119+
if (next.request.lockType === "write") {
120+
// Write lock: need no readers and no writer
121+
if (state.readers.size === 0 && state.writer === null) {
122+
state.queue.shift()
123+
clearTimeout(next.timer)
124+
state.writer = next.request.taskId
125+
next.resolve(this.createHandle(next.request.taskId, normalizedPath, "write"))
126+
}
127+
128+
// Whether granted or not, stop processing -- a pending writer
129+
// blocks subsequent requests to maintain FIFO + writer priority.
130+
break
131+
} else {
132+
// Read lock: allowed if no writer and no pending writer ahead
133+
if (state.writer === null) {
134+
state.queue.shift()
135+
clearTimeout(next.timer)
136+
state.readers.add(next.request.taskId)
137+
next.resolve(this.createHandle(next.request.taskId, normalizedPath, "read"))
138+
// Continue processing: more reads can be granted concurrently
139+
} else {
140+
// Writer is active, can't grant reads
141+
break
142+
}
143+
}
144+
}
145+
}
146+
147+
/**
148+
* Acquire a lock on a file.
149+
*
150+
* @param request - The lock request details.
151+
* @returns A promise that resolves with a LockHandle when the lock is granted.
152+
* @throws Error if the request times out.
153+
*/
154+
async acquireLock(request: LockRequest): Promise<LockHandle> {
155+
const normalizedPath = this.normalizePath(request.filePath)
156+
const state = this.getOrCreateState(normalizedPath)
157+
const timeout = request.timeout ?? DEFAULT_TIMEOUT_MS
158+
159+
// Try immediate grant
160+
if (request.lockType === "read") {
161+
if (state.writer === null && state.queue.length === 0) {
162+
state.readers.add(request.taskId)
163+
return this.createHandle(request.taskId, normalizedPath, "read")
164+
}
165+
} else {
166+
// Write lock
167+
if (state.writer === null && state.readers.size === 0 && state.queue.length === 0) {
168+
state.writer = request.taskId
169+
return this.createHandle(request.taskId, normalizedPath, "write")
170+
}
171+
}
172+
173+
// Queue the request
174+
return new Promise<LockHandle>((resolve, reject) => {
175+
const timer = setTimeout(() => {
176+
// Remove from queue on timeout
177+
const idx = state.queue.findIndex((p) => p === pending)
178+
179+
if (idx !== -1) {
180+
state.queue.splice(idx, 1)
181+
}
182+
183+
this.cleanupIfEmpty(normalizedPath)
184+
reject(
185+
new Error(
186+
`Lock acquisition timed out after ${timeout}ms for ${request.lockType} lock on "${request.filePath}" (task: ${request.taskId})`,
187+
),
188+
)
189+
}, timeout)
190+
191+
const pending: PendingLock = {
192+
request,
193+
resolve,
194+
reject,
195+
timer,
196+
}
197+
198+
state.queue.push(pending)
199+
})
200+
}
201+
202+
/**
203+
* Release all locks held by a specific task.
204+
* Called when a task completes, is aborted, or is disposed.
205+
*/
206+
releaseAllForTask(taskId: string): void {
207+
// Collect paths to process (avoid mutating map during iteration)
208+
const pathsToProcess: string[] = []
209+
210+
for (const [normalizedPath, state] of this.locks) {
211+
if (state.readers.has(taskId) || state.writer === taskId) {
212+
pathsToProcess.push(normalizedPath)
213+
}
214+
215+
// Also remove any pending requests from this task
216+
const pendingIndices: number[] = []
217+
218+
for (let i = 0; i < state.queue.length; i++) {
219+
if (state.queue[i].request.taskId === taskId) {
220+
pendingIndices.push(i)
221+
}
222+
}
223+
224+
// Remove pending requests in reverse order to maintain indices
225+
for (let i = pendingIndices.length - 1; i >= 0; i--) {
226+
const pending = state.queue[pendingIndices[i]]
227+
clearTimeout(pending.timer)
228+
pending.reject(new Error(`Lock request cancelled: task ${taskId} was released`))
229+
state.queue.splice(pendingIndices[i], 1)
230+
}
231+
}
232+
233+
// Release held locks and process queues
234+
for (const normalizedPath of pathsToProcess) {
235+
const state = this.locks.get(normalizedPath)
236+
237+
if (!state) {
238+
continue
239+
}
240+
241+
state.readers.delete(taskId)
242+
243+
if (state.writer === taskId) {
244+
state.writer = null
245+
}
246+
247+
this.processQueue(normalizedPath)
248+
this.cleanupIfEmpty(normalizedPath)
249+
}
250+
}
251+
252+
/**
253+
* Check if a file is currently locked for writing.
254+
*/
255+
isWriteLocked(filePath: string): boolean {
256+
const normalizedPath = this.normalizePath(filePath)
257+
const state = this.locks.get(normalizedPath)
258+
return state?.writer !== null && state?.writer !== undefined
259+
}
260+
261+
/**
262+
* Check if a specific task holds a lock on a file.
263+
*/
264+
hasLock(taskId: string, filePath: string, lockType?: LockType): boolean {
265+
const normalizedPath = this.normalizePath(filePath)
266+
const state = this.locks.get(normalizedPath)
267+
268+
if (!state) {
269+
return false
270+
}
271+
272+
if (lockType === "read") {
273+
return state.readers.has(taskId)
274+
}
275+
276+
if (lockType === "write") {
277+
return state.writer === taskId
278+
}
279+
280+
// No lockType specified: check both
281+
return state.readers.has(taskId) || state.writer === taskId
282+
}
283+
284+
/**
285+
* Get the current lock state for a file (for debugging/UI).
286+
* Returns undefined if no locks exist for the file.
287+
*/
288+
getLockState(filePath: string): FileLockState | undefined {
289+
const normalizedPath = this.normalizePath(filePath)
290+
return this.locks.get(normalizedPath)
291+
}
292+
293+
/**
294+
* Get all active lock handles for a specific task.
295+
* Note: These are informational snapshots, not actual handles.
296+
*/
297+
getLocksForTask(taskId: string): Array<{ filePath: string; lockType: LockType }> {
298+
const result: Array<{ filePath: string; lockType: LockType }> = []
299+
300+
for (const [normalizedPath, state] of this.locks) {
301+
if (state.readers.has(taskId)) {
302+
result.push({ filePath: normalizedPath, lockType: "read" })
303+
}
304+
305+
if (state.writer === taskId) {
306+
result.push({ filePath: normalizedPath, lockType: "write" })
307+
}
308+
}
309+
310+
return result
311+
}
312+
313+
/**
314+
* Reset all state. Primarily for testing.
315+
*/
316+
reset(): void {
317+
// Cancel all pending timeouts
318+
for (const [, state] of this.locks) {
319+
for (const pending of state.queue) {
320+
clearTimeout(pending.timer)
321+
}
322+
}
323+
324+
this.locks.clear()
325+
}
326+
}

0 commit comments

Comments
 (0)