@cleocode/worktree 2026.11.1-canary.9 → 2026.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.source-stamp.json +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/worktree-create.d.ts +3 -1
- package/dist/worktree-create.d.ts.map +1 -1
- package/dist/worktree-create.js +99 -8
- package/dist/worktree-create.js.map +1 -1
- package/dist/worktree-dispose.d.ts +53 -0
- package/dist/worktree-dispose.d.ts.map +1 -1
- package/dist/worktree-dispose.js +86 -6
- package/dist/worktree-dispose.js.map +1 -1
- package/dist/worktree-lock.d.ts +56 -7
- package/dist/worktree-lock.d.ts.map +1 -1
- package/dist/worktree-lock.js +315 -100
- package/dist/worktree-lock.js.map +1 -1
- package/package.json +4 -4
package/dist/worktree-lock.d.ts
CHANGED
|
@@ -35,9 +35,11 @@
|
|
|
35
35
|
* reclaimed on age. Anything else is `E_WORKTREE_LOCKED`.
|
|
36
36
|
* - **Re-entry:** the same caller (session id, agent id, owner pid and start
|
|
37
37
|
* time all equal) re-enters its own lock, so an orchestrator can retry.
|
|
38
|
-
* - **
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* - **Every lock-file write is serialised** through a second O_EXCL mutex file
|
|
39
|
+
* (`<lock>.reclaim`): publish, re-entry, reclaim, heartbeat, release and
|
|
40
|
+
* session-end release (T14407). A holder of the mutex (a dispose under
|
|
41
|
+
* {@link withWorktreeTaskLockHeld}) therefore sees no lock transition at all
|
|
42
|
+
* until it lets go.
|
|
41
43
|
*
|
|
42
44
|
* @module worktree-lock
|
|
43
45
|
* @task T12506
|
|
@@ -176,6 +178,51 @@ export declare function isSameLockCaller(holder: WorktreeLockRecord, candidate:
|
|
|
176
178
|
* @returns The record, or `null` when no valid lock exists.
|
|
177
179
|
*/
|
|
178
180
|
export declare function readWorktreeTaskLock(projectHash: string, taskId: string): WorktreeLockRecord | null;
|
|
181
|
+
/**
|
|
182
|
+
* A file's identity: device and inode as `bigint` (T14407). A `Number` cannot
|
|
183
|
+
* hold every 64-bit file id (Windows), so two files could compare equal.
|
|
184
|
+
*/
|
|
185
|
+
export interface FileIdentity {
|
|
186
|
+
dev: bigint;
|
|
187
|
+
ino: bigint;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* True when two identities name the same file (`dev` and `ino` compared as `bigint`).
|
|
191
|
+
*
|
|
192
|
+
* @param a - First identity.
|
|
193
|
+
* @param b - Second identity.
|
|
194
|
+
* @returns Whether both name one file.
|
|
195
|
+
*/
|
|
196
|
+
export declare function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean;
|
|
197
|
+
/** Outcome of {@link withWorktreeTaskLockHeld}. */
|
|
198
|
+
export type WorktreeTaskLockHold<T> = {
|
|
199
|
+
held: true;
|
|
200
|
+
value: T;
|
|
201
|
+
} | {
|
|
202
|
+
held: false;
|
|
203
|
+
reason: string;
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Run `fn` while `token` provably holds the task lock (T14407).
|
|
207
|
+
*
|
|
208
|
+
* Takes the same mutex every lock transition takes, re-reads the lock under
|
|
209
|
+
* it, and runs `fn` only when the record on disk carries `token`; the mutex is
|
|
210
|
+
* held until `fn` returns, so no re-entry or reclaim can replace the lock in
|
|
211
|
+
* between. Fails closed: a busy mutex, a missing, unreadable or foreign lock,
|
|
212
|
+
* or a read error means `fn` never runs. `fn` must not take the lock mutex
|
|
213
|
+
* itself (every lock-file write does); it releases the lock through the
|
|
214
|
+
* `releaseLock` callback it receives, which removes it only while it still
|
|
215
|
+
* carries `token`.
|
|
216
|
+
*
|
|
217
|
+
* @param projectHash - Project hash.
|
|
218
|
+
* @param taskId - Task id.
|
|
219
|
+
* @param token - Token of the acquisition that must hold the lock.
|
|
220
|
+
* @param fn - Work to do while the lock is held; receives the record and a
|
|
221
|
+
* `releaseLock` callback (true when it removed the lock).
|
|
222
|
+
* @param now - Clock (default `Date.now`).
|
|
223
|
+
* @returns `fn`'s value, or why it did not run.
|
|
224
|
+
*/
|
|
225
|
+
export declare function withWorktreeTaskLockHeld<T>(projectHash: string, taskId: string, token: string, fn: (record: WorktreeLockRecord, releaseLock: () => boolean) => T, now?: () => number): WorktreeTaskLockHold<T>;
|
|
179
226
|
/**
|
|
180
227
|
* Type guard for the error thrown by {@link acquireWorktreeTaskLock}.
|
|
181
228
|
*
|
|
@@ -207,8 +254,10 @@ export declare function acquireWorktreeTaskLock(options: AcquireWorktreeTaskLock
|
|
|
207
254
|
*/
|
|
208
255
|
export declare function heartbeatWorktreeTaskLock(projectHash: string, taskId: string, token: string, now?: () => number): WorktreeLockRecord | null;
|
|
209
256
|
/**
|
|
210
|
-
* Release the per-task lock. With `token`, only that
|
|
211
|
-
* removed (a lock since reclaimed or re-entered by
|
|
257
|
+
* Release the per-task lock under its mutex (T14407). With `token`, only that
|
|
258
|
+
* acquisition's lock is removed (a lock since reclaimed or re-entered by
|
|
259
|
+
* someone else is left alone). A busy mutex removes nothing. Inside
|
|
260
|
+
* {@link withWorktreeTaskLockHeld}, use the `releaseLock` callback instead.
|
|
212
261
|
*
|
|
213
262
|
* @param projectHash - Project hash.
|
|
214
263
|
* @param taskId - Task id.
|
|
@@ -220,8 +269,8 @@ export declare function releaseWorktreeTaskLock(projectHash: string, taskId: str
|
|
|
220
269
|
* Release every per-task worktree lock of a project whose holder is
|
|
221
270
|
* `sessionId` (T13425): `cleo session end` frees the worktrees that session
|
|
222
271
|
* holds, so a successor's `--resume` is not refused while the harness pid that
|
|
223
|
-
* spawned it lives on. Each lock is removed
|
|
224
|
-
* holder took in the meantime is left alone.
|
|
272
|
+
* spawned it lives on. Each lock is re-checked and removed under its mutex, so
|
|
273
|
+
* a lock another holder took in the meantime is left alone (T14407).
|
|
225
274
|
*
|
|
226
275
|
* @param projectHash - Project hash.
|
|
227
276
|
* @param sessionId - The ended session.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worktree-lock.d.ts","sourceRoot":"","sources":["../src/worktree-lock.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"worktree-lock.d.ts","sourceRoot":"","sources":["../src/worktree-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAqBH,OAAO,KAAK,EACV,uBAAuB,EACvB,kBAAkB,EAClB,kBAAkB,EAClB,wBAAwB,EACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,uBAAuB,EAAE,MAAM,oCAAoC,CAAC;AAK7E,iFAAiF;AACjF,eAAO,MAAM,4BAA4B,QAAqB,CAAC;AAE/D,4EAA4E;AAC5E,eAAO,MAAM,qBAAqB,8BAA8B,CAAC;AAoBjE,wCAAwC;AACxC,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,KAAK,EAAE,OAAO,CAAC;IACf,+EAA+E;IAC/E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,uDAAuD;AACvD,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,eAAe,CAAC;AAE5D,0CAA0C;AAC1C,MAAM,WAAW,sBAAsB;IACrC,2EAA2E;IAC3E,IAAI,EAAE,OAAO,CAAC;IACd,4CAA4C;IAC5C,MAAM,CAAC,EAAE,UAAU,GAAG,cAAc,GAAG,iBAAiB,GAAG,eAAe,CAAC;IAC3E,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,mDAAmD;AACnD,MAAM,WAAW,8BAA8B;IAC7C,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,4BAA4B;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,kCAAkC;IAClC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,qFAAqF;IACrF,YAAY,CAAC,EAAE,wBAAwB,CAAC;CACzC;AAED,2DAA2D;AAC3D,MAAM,WAAW,mBAAoB,SAAQ,KAAK;IAChD,kCAAkC;IAClC,IAAI,EAAE,OAAO,uBAAuB,CAAC,iBAAiB,CAAC;IACvD,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,MAAM,EAAE,kBAAkB,GAAG,IAAI,CAAC;IAClC,uGAAuG;IACvG,MAAM,EAAE,MAAM,GAAG,kBAAkB,CAAC;IACpC,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAKrF;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAa/D;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CASzD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,GAAG,IAAI,CAOlD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE;IACJ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,YAAY,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,YAAY,CAAC,EAAE,wBAAwB,CAAC;CACzC,GACA,sBAAsB,CA4BxB;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,kBAAkB,EAC1B,SAAS,EAAE,kBAAkB,GAC5B,OAAO,CAUT;AA6CD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,kBAAkB,GAAG,IAAI,CAO3B;AA6ED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,YAAY,GAAG,OAAO,CAE1E;AAwOD,mDAAmD;AACnD,MAAM,MAAM,oBAAoB,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjG;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,EACxC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,CAAC,MAAM,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,OAAO,KAAK,CAAC,EACjE,GAAG,GAAE,MAAM,MAAiB,GAC3B,oBAAoB,CAAC,CAAC,CAAC,CA4BzB;AA8DD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,mBAAmB,CAK9E;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,8BAA8B,GACtC,uBAAuB,CAQzB;AAmGD;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,GAAG,GAAE,MAAM,MAAiB,GAC3B,kBAAkB,GAAG,IAAI,CAqB3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAQT;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kCAAkC,CAChD,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,kBAAkB,EAAE,CAoBtB"}
|
package/dist/worktree-lock.js
CHANGED
|
@@ -35,18 +35,21 @@
|
|
|
35
35
|
* reclaimed on age. Anything else is `E_WORKTREE_LOCKED`.
|
|
36
36
|
* - **Re-entry:** the same caller (session id, agent id, owner pid and start
|
|
37
37
|
* time all equal) re-enters its own lock, so an orchestrator can retry.
|
|
38
|
-
* - **
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* - **Every lock-file write is serialised** through a second O_EXCL mutex file
|
|
39
|
+
* (`<lock>.reclaim`): publish, re-entry, reclaim, heartbeat, release and
|
|
40
|
+
* session-end release (T14407). A holder of the mutex (a dispose under
|
|
41
|
+
* {@link withWorktreeTaskLockHeld}) therefore sees no lock transition at all
|
|
42
|
+
* until it lets go.
|
|
41
43
|
*
|
|
42
44
|
* @module worktree-lock
|
|
43
45
|
* @task T12506
|
|
44
46
|
*/
|
|
45
47
|
import { execFileSync } from 'node:child_process';
|
|
46
48
|
import { randomUUID } from 'node:crypto';
|
|
47
|
-
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
|
|
49
|
+
import { closeSync, fstatSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
|
|
48
50
|
import { hostname } from 'node:os';
|
|
49
51
|
import { dirname } from 'node:path';
|
|
52
|
+
import { threadId } from 'node:worker_threads';
|
|
50
53
|
import { BRANCH_LOCK_ERROR_CODES } from '@cleocode/contracts/branch-lock.js';
|
|
51
54
|
import { ExitCode } from '@cleocode/contracts/exit-codes.js';
|
|
52
55
|
import { PS_STABLE_ENV } from '@cleocode/contracts/process-probe.js';
|
|
@@ -325,61 +328,98 @@ function publishExclusive(lockPath, record) {
|
|
|
325
328
|
function sleepSync(ms) {
|
|
326
329
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
327
330
|
}
|
|
331
|
+
/** This process's start time, read once (a `ps` call). */
|
|
332
|
+
let ownStartTime;
|
|
328
333
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
334
|
+
* True when two identities name the same file (`dev` and `ino` compared as `bigint`).
|
|
335
|
+
*
|
|
336
|
+
* @param a - First identity.
|
|
337
|
+
* @param b - Second identity.
|
|
338
|
+
* @returns Whether both name one file.
|
|
332
339
|
*/
|
|
333
|
-
function
|
|
334
|
-
|
|
340
|
+
export function sameFileIdentity(a, b) {
|
|
341
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
342
|
+
}
|
|
343
|
+
/** Parse a mutex file's holder, or `null` when it is not a record. */
|
|
344
|
+
function parseMutexHolder(raw) {
|
|
335
345
|
try {
|
|
336
|
-
|
|
346
|
+
const parsed = JSON.parse(raw);
|
|
347
|
+
if (typeof parsed.token === 'string' && typeof parsed.pid === 'number') {
|
|
348
|
+
return {
|
|
349
|
+
token: parsed.token,
|
|
350
|
+
pid: parsed.pid,
|
|
351
|
+
processStartedAt: typeof parsed.processStartedAt === 'string' ? parsed.processStartedAt : null,
|
|
352
|
+
threadId: typeof parsed.threadId === 'number' ? parsed.threadId : -1,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
337
355
|
}
|
|
338
356
|
catch {
|
|
339
|
-
|
|
357
|
+
/* a bare-token mutex from an older writer */
|
|
340
358
|
}
|
|
341
|
-
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Read a mutex file's holder and identity from ONE open descriptor (`fstat` on
|
|
363
|
+
* the fd that was read), so a mutex replaced between the read and the stat can
|
|
364
|
+
* never pair the old holder with the new file (T14407).
|
|
365
|
+
*
|
|
366
|
+
* @returns The snapshot, or `null` when the file is missing.
|
|
367
|
+
*/
|
|
368
|
+
function readMutexSnapshot(mutexPath) {
|
|
369
|
+
let fd;
|
|
342
370
|
try {
|
|
343
|
-
|
|
371
|
+
fd = openSync(mutexPath, 'r');
|
|
344
372
|
}
|
|
345
373
|
catch {
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
if (ino === staleIno) {
|
|
349
|
-
unlinkQuiet(graveyard);
|
|
350
|
-
return;
|
|
374
|
+
return null;
|
|
351
375
|
}
|
|
352
376
|
try {
|
|
353
|
-
|
|
377
|
+
const raw = readFileSync(fd, 'utf-8');
|
|
378
|
+
const st = fstatSync(fd, { bigint: true });
|
|
379
|
+
return {
|
|
380
|
+
raw,
|
|
381
|
+
holder: parseMutexHolder(raw),
|
|
382
|
+
id: { dev: st.dev, ino: st.ino },
|
|
383
|
+
mtimeMs: Number(st.mtimeMs),
|
|
384
|
+
};
|
|
354
385
|
}
|
|
355
386
|
catch {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
/* best effort */
|
|
361
|
-
}
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
closeSync(fd);
|
|
362
391
|
}
|
|
363
|
-
unlinkQuiet(graveyard);
|
|
364
392
|
}
|
|
365
393
|
/**
|
|
366
|
-
* Take the per-lock mutex (`<lock>.reclaim`, O_EXCL
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
394
|
+
* Take the per-lock mutex (`<lock>.reclaim`, O_EXCL). It serialises every
|
|
395
|
+
* write of the lock file (publish, reclaim, re-entry, heartbeat, release) and
|
|
396
|
+
* the whole of {@link withWorktreeTaskLockHeld}.
|
|
397
|
+
*
|
|
398
|
+
* The file names its holder (pid, start time, thread). A mutex older than
|
|
399
|
+
* {@link RECLAIM_MUTEX_STALE_MS} is broken, under the breaker lock, only when that holder is
|
|
400
|
+
* provably dead and the file is still the one read (holder and identity read from one descriptor), so a long hold by a live process (a forced dispose) is never
|
|
401
|
+
* broken as stale, even where its start time cannot be read (T14407). A mutex this very thread holds is reported
|
|
402
|
+
* busy at once: waiting would block the holder, which can never release it.
|
|
370
403
|
*
|
|
371
404
|
* @returns A release function (removes the mutex only if it is still ours),
|
|
372
405
|
* or `null` when the mutex stayed busy.
|
|
373
406
|
*/
|
|
374
407
|
function takeLockMutex(lockPath, now) {
|
|
375
408
|
const mutexPath = `${lockPath}.reclaim`;
|
|
376
|
-
|
|
409
|
+
if (ownStartTime === undefined)
|
|
410
|
+
ownStartTime = readProcessStartTime(process.pid);
|
|
411
|
+
const content = JSON.stringify({
|
|
412
|
+
token: randomUUID(),
|
|
413
|
+
pid: process.pid,
|
|
414
|
+
processStartedAt: ownStartTime,
|
|
415
|
+
threadId,
|
|
416
|
+
});
|
|
377
417
|
for (let attempt = 0; attempt < RECLAIM_ATTEMPTS; attempt++) {
|
|
378
418
|
try {
|
|
379
|
-
writeExclusive(mutexPath,
|
|
419
|
+
writeExclusive(mutexPath, content);
|
|
380
420
|
return () => {
|
|
381
421
|
try {
|
|
382
|
-
if (readFileSync(mutexPath, 'utf-8') ===
|
|
422
|
+
if (readFileSync(mutexPath, 'utf-8') === content)
|
|
383
423
|
unlinkSync(mutexPath);
|
|
384
424
|
}
|
|
385
425
|
catch {
|
|
@@ -390,19 +430,199 @@ function takeLockMutex(lockPath, now) {
|
|
|
390
430
|
catch (err) {
|
|
391
431
|
if (err.code !== 'EEXIST')
|
|
392
432
|
throw err;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
433
|
+
const snap = readMutexSnapshot(mutexPath);
|
|
434
|
+
if (snap !== null) {
|
|
435
|
+
const { holder } = snap;
|
|
436
|
+
if (holder !== null && holder.pid === process.pid && holder.threadId === threadId) {
|
|
437
|
+
return null; // held by this thread: re-entrant from inside a hold
|
|
438
|
+
}
|
|
439
|
+
// Breaks are serialised under `<mutex>.break`; the file is unlinked
|
|
440
|
+
// only while it is still the one whose holder was judged dead.
|
|
441
|
+
const nowMs = now();
|
|
442
|
+
if (isStaleMutex(snap, nowMs))
|
|
443
|
+
breakStaleMutex(mutexPath, snap, nowMs);
|
|
400
444
|
}
|
|
401
445
|
sleepSync(RECLAIM_BACKOFF_MS);
|
|
402
446
|
}
|
|
403
447
|
}
|
|
404
448
|
return null;
|
|
405
449
|
}
|
|
450
|
+
/** A mutex (or breaker) past {@link RECLAIM_MUTEX_STALE_MS} whose holder is provably dead. */
|
|
451
|
+
function isStaleMutex(snap, nowMs) {
|
|
452
|
+
return nowMs - snap.mtimeMs > RECLAIM_MUTEX_STALE_MS && !isMutexHolderAlive(snap.holder);
|
|
453
|
+
}
|
|
454
|
+
/** True when `current` is still the very file `snap` read: same identity and content. */
|
|
455
|
+
function isSameMutex(current, snap) {
|
|
456
|
+
return sameFileIdentity(current.id, snap.id) && current.raw === snap.raw;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Take the breaker lock `<mutex>.break` (O_EXCL). A breaker whose holder is
|
|
460
|
+
* provably dead is removed (only while it is still the file judged dead) and
|
|
461
|
+
* the caller retries on its next attempt; a live one is never touched.
|
|
462
|
+
*
|
|
463
|
+
* @returns A release function, or `null` when another breaker holds it.
|
|
464
|
+
*/
|
|
465
|
+
function takeBreaker(breakerPath, content, nowMs) {
|
|
466
|
+
try {
|
|
467
|
+
writeExclusive(breakerPath, content);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
if (err.code !== 'EEXIST')
|
|
471
|
+
throw err;
|
|
472
|
+
const snap = readMutexSnapshot(breakerPath);
|
|
473
|
+
if (snap !== null && isStaleMutex(snap, nowMs)) {
|
|
474
|
+
const current = readMutexSnapshot(breakerPath);
|
|
475
|
+
if (current !== null && isSameMutex(current, snap))
|
|
476
|
+
unlinkQuiet(breakerPath);
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
return () => {
|
|
481
|
+
try {
|
|
482
|
+
if (readFileSync(breakerPath, 'utf-8') === content)
|
|
483
|
+
unlinkSync(breakerPath);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
/* already gone */
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Remove an abandoned mutex (T14407). A path cannot be compared-and-deleted
|
|
492
|
+
* atomically, so every break runs under the breaker lock: under it the mutex
|
|
493
|
+
* is re-read through one descriptor and unlinked only if it is still the file
|
|
494
|
+
* `stale` read. Acquisition is O_EXCL, which never replaces an existing mutex,
|
|
495
|
+
* so with breaks serialised a live mutex is never moved or deleted.
|
|
496
|
+
*
|
|
497
|
+
* Residual: two contenders that both find the same DEAD breaker can race to
|
|
498
|
+
* remove it; that needs a breaker that died mid-break and stayed stale.
|
|
499
|
+
*/
|
|
500
|
+
function breakStaleMutex(mutexPath, stale, nowMs) {
|
|
501
|
+
const release = takeBreaker(`${mutexPath}.break`, JSON.stringify({
|
|
502
|
+
token: randomUUID(),
|
|
503
|
+
pid: process.pid,
|
|
504
|
+
processStartedAt: ownStartTime ?? null,
|
|
505
|
+
threadId,
|
|
506
|
+
}), nowMs);
|
|
507
|
+
if (release === null)
|
|
508
|
+
return;
|
|
509
|
+
try {
|
|
510
|
+
const current = readMutexSnapshot(mutexPath);
|
|
511
|
+
if (current !== null && isSameMutex(current, stale))
|
|
512
|
+
unlinkQuiet(mutexPath);
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
515
|
+
release();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* False only when the mutex holder is provably dead: its pid is gone, or alive
|
|
520
|
+
* with a different start time (recycled). An unknown start time on either side
|
|
521
|
+
* (Windows, a failed `ps`) keeps it alive (T14407). A mutex naming no holder
|
|
522
|
+
* (an older bare-token writer) is treated as dead once stale.
|
|
523
|
+
*/
|
|
524
|
+
function isMutexHolderAlive(holder) {
|
|
525
|
+
if (holder === null)
|
|
526
|
+
return false;
|
|
527
|
+
const liveness = probeProcess(holder.pid);
|
|
528
|
+
if (!liveness.alive)
|
|
529
|
+
return false;
|
|
530
|
+
return (holder.processStartedAt === null ||
|
|
531
|
+
liveness.startedAt === null ||
|
|
532
|
+
liveness.startedAt === holder.processStartedAt);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Unlink the lock file when `matches` accepts what is on disk. The caller
|
|
536
|
+
* holds the lock mutex.
|
|
537
|
+
*
|
|
538
|
+
* @returns What was removed, or `null` when nothing was (absent, rejected or an I/O error).
|
|
539
|
+
*/
|
|
540
|
+
function unlinkLockIf(lockPath, matches) {
|
|
541
|
+
try {
|
|
542
|
+
const current = readLockAt(lockPath, Date.now());
|
|
543
|
+
if (current.kind === 'absent' || !matches(current))
|
|
544
|
+
return null;
|
|
545
|
+
unlinkSync(lockPath);
|
|
546
|
+
return current;
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* {@link unlinkLockIf} under the lock mutex. A busy mutex or an I/O error
|
|
554
|
+
* removes nothing (fail closed: the lock stays).
|
|
555
|
+
*/
|
|
556
|
+
function releaseLockIf(lockPath, matches) {
|
|
557
|
+
let release;
|
|
558
|
+
try {
|
|
559
|
+
release = takeLockMutex(lockPath, Date.now);
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
if (release === null)
|
|
565
|
+
return null;
|
|
566
|
+
try {
|
|
567
|
+
return unlinkLockIf(lockPath, matches);
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
release();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Run `fn` while `token` provably holds the task lock (T14407).
|
|
575
|
+
*
|
|
576
|
+
* Takes the same mutex every lock transition takes, re-reads the lock under
|
|
577
|
+
* it, and runs `fn` only when the record on disk carries `token`; the mutex is
|
|
578
|
+
* held until `fn` returns, so no re-entry or reclaim can replace the lock in
|
|
579
|
+
* between. Fails closed: a busy mutex, a missing, unreadable or foreign lock,
|
|
580
|
+
* or a read error means `fn` never runs. `fn` must not take the lock mutex
|
|
581
|
+
* itself (every lock-file write does); it releases the lock through the
|
|
582
|
+
* `releaseLock` callback it receives, which removes it only while it still
|
|
583
|
+
* carries `token`.
|
|
584
|
+
*
|
|
585
|
+
* @param projectHash - Project hash.
|
|
586
|
+
* @param taskId - Task id.
|
|
587
|
+
* @param token - Token of the acquisition that must hold the lock.
|
|
588
|
+
* @param fn - Work to do while the lock is held; receives the record and a
|
|
589
|
+
* `releaseLock` callback (true when it removed the lock).
|
|
590
|
+
* @param now - Clock (default `Date.now`).
|
|
591
|
+
* @returns `fn`'s value, or why it did not run.
|
|
592
|
+
*/
|
|
593
|
+
export function withWorktreeTaskLockHeld(projectHash, taskId, token, fn, now = Date.now) {
|
|
594
|
+
const lockPath = resolveWorktreeTaskLockPath(projectHash, taskId);
|
|
595
|
+
let release;
|
|
596
|
+
try {
|
|
597
|
+
release = takeLockMutex(lockPath, now);
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
return { held: false, reason: `the lock mutex could not be taken (${String(err)})` };
|
|
601
|
+
}
|
|
602
|
+
if (release === null)
|
|
603
|
+
return { held: false, reason: 'the lock mutex stayed busy' };
|
|
604
|
+
try {
|
|
605
|
+
let current;
|
|
606
|
+
try {
|
|
607
|
+
current = readLockAt(lockPath, now());
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
return { held: false, reason: `the task lock could not be read (${String(err)})` };
|
|
611
|
+
}
|
|
612
|
+
if (current.kind === 'absent')
|
|
613
|
+
return { held: false, reason: 'the task lock was released' };
|
|
614
|
+
if (current.kind === 'unreadable')
|
|
615
|
+
return { held: false, reason: 'the task lock is unreadable' };
|
|
616
|
+
if (current.record.token !== token) {
|
|
617
|
+
return { held: false, reason: 'the task lock passed to another acquisition' };
|
|
618
|
+
}
|
|
619
|
+
const releaseLock = () => unlinkLockIf(lockPath, (r) => r.kind === 'record' && r.record.token === token) !== null;
|
|
620
|
+
return { held: true, value: fn(current.record, releaseLock) };
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
release();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
406
626
|
/** Build the typed E_WORKTREE_LOCKED error naming the holder. */
|
|
407
627
|
function lockedError(taskId, lockPath, holder) {
|
|
408
628
|
const who = [
|
|
@@ -506,49 +726,52 @@ function acquireUnchecked(options, lockPath) {
|
|
|
506
726
|
acquiredAt: stamp,
|
|
507
727
|
heartbeatAt: stamp,
|
|
508
728
|
};
|
|
729
|
+
let backoff = false;
|
|
509
730
|
for (let pass = 0; pass < MAX_ACQUIRE_PASSES; pass++) {
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
continue; // released between create and read — retry
|
|
515
|
-
let status;
|
|
516
|
-
let reason;
|
|
517
|
-
if (existing.kind === 'unreadable') {
|
|
518
|
-
if (existing.ageMs < UNREADABLE_GRACE_MS) {
|
|
519
|
-
sleepSync(RECLAIM_BACKOFF_MS); // O_EXCL fallback writer mid-write
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
status = 'reclaimed';
|
|
523
|
-
reason = 'unreadable';
|
|
524
|
-
}
|
|
525
|
-
else if (isSameLockCaller(existing.record, record)) {
|
|
526
|
-
status = 'reentered';
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
const verdict = assessWorktreeLockHolder(existing.record, {
|
|
530
|
-
ttlMs,
|
|
531
|
-
now: now(),
|
|
532
|
-
probe,
|
|
533
|
-
deviceId,
|
|
534
|
-
...(options.sessionProbe ? { sessionProbe: options.sessionProbe } : {}),
|
|
535
|
-
});
|
|
536
|
-
if (verdict.live)
|
|
537
|
-
throw lockedError(taskId, lockPath, existing.record);
|
|
538
|
-
status = 'reclaimed';
|
|
539
|
-
reason = verdict.reason;
|
|
540
|
-
}
|
|
731
|
+
if (backoff)
|
|
732
|
+
sleepSync(RECLAIM_BACKOFF_MS); // O_EXCL fallback writer mid-write
|
|
733
|
+
backoff = false;
|
|
734
|
+
// Every write of the lock file happens under its mutex (T14407).
|
|
541
735
|
const release = takeLockMutex(lockPath, now);
|
|
542
|
-
if (release === null)
|
|
543
|
-
|
|
736
|
+
if (release === null) {
|
|
737
|
+
// Someone is transitioning or disposing the lock: refuse, naming its holder.
|
|
738
|
+
const busy = readLockAt(lockPath, now());
|
|
739
|
+
if (busy.kind === 'record')
|
|
740
|
+
throw lockedError(taskId, lockPath, busy.record);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
544
743
|
try {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
744
|
+
if (publishExclusive(lockPath, record))
|
|
745
|
+
return { status: 'acquired', lockPath, record };
|
|
746
|
+
const existing = readLockAt(lockPath, now());
|
|
747
|
+
if (existing.kind === 'absent')
|
|
748
|
+
continue; // released between create and read — retry
|
|
749
|
+
let status;
|
|
750
|
+
let reason;
|
|
751
|
+
if (existing.kind === 'unreadable') {
|
|
752
|
+
if (existing.ageMs < UNREADABLE_GRACE_MS) {
|
|
753
|
+
backoff = true;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
status = 'reclaimed';
|
|
757
|
+
reason = 'unreadable';
|
|
758
|
+
}
|
|
759
|
+
else if (isSameLockCaller(existing.record, record)) {
|
|
760
|
+
status = 'reentered';
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
const verdict = assessWorktreeLockHolder(existing.record, {
|
|
764
|
+
ttlMs,
|
|
765
|
+
now: now(),
|
|
766
|
+
probe,
|
|
767
|
+
deviceId,
|
|
768
|
+
...(options.sessionProbe ? { sessionProbe: options.sessionProbe } : {}),
|
|
769
|
+
});
|
|
770
|
+
if (verdict.live)
|
|
771
|
+
throw lockedError(taskId, lockPath, existing.record);
|
|
772
|
+
status = 'reclaimed';
|
|
773
|
+
reason = verdict.reason;
|
|
774
|
+
}
|
|
552
775
|
unlinkQuiet(lockPath);
|
|
553
776
|
if (publishExclusive(lockPath, record)) {
|
|
554
777
|
return {
|
|
@@ -607,8 +830,10 @@ export function heartbeatWorktreeTaskLock(projectHash, taskId, token, now = Date
|
|
|
607
830
|
}
|
|
608
831
|
}
|
|
609
832
|
/**
|
|
610
|
-
* Release the per-task lock. With `token`, only that
|
|
611
|
-
* removed (a lock since reclaimed or re-entered by
|
|
833
|
+
* Release the per-task lock under its mutex (T14407). With `token`, only that
|
|
834
|
+
* acquisition's lock is removed (a lock since reclaimed or re-entered by
|
|
835
|
+
* someone else is left alone). A busy mutex removes nothing. Inside
|
|
836
|
+
* {@link withWorktreeTaskLockHeld}, use the `releaseLock` callback instead.
|
|
612
837
|
*
|
|
613
838
|
* @param projectHash - Project hash.
|
|
614
839
|
* @param taskId - Task id.
|
|
@@ -617,25 +842,14 @@ export function heartbeatWorktreeTaskLock(projectHash, taskId, token, now = Date
|
|
|
617
842
|
*/
|
|
618
843
|
export function releaseWorktreeTaskLock(projectHash, taskId, token) {
|
|
619
844
|
const lockPath = resolveWorktreeTaskLockPath(projectHash, taskId);
|
|
620
|
-
|
|
621
|
-
if (token !== undefined) {
|
|
622
|
-
const current = readLockAt(lockPath, Date.now());
|
|
623
|
-
if (current.kind !== 'record' || current.record.token !== token)
|
|
624
|
-
return false;
|
|
625
|
-
}
|
|
626
|
-
unlinkSync(lockPath);
|
|
627
|
-
return true;
|
|
628
|
-
}
|
|
629
|
-
catch {
|
|
630
|
-
return false;
|
|
631
|
-
}
|
|
845
|
+
return (releaseLockIf(lockPath, (r) => token === undefined || (r.kind === 'record' && r.record.token === token)) !== null);
|
|
632
846
|
}
|
|
633
847
|
/**
|
|
634
848
|
* Release every per-task worktree lock of a project whose holder is
|
|
635
849
|
* `sessionId` (T13425): `cleo session end` frees the worktrees that session
|
|
636
850
|
* holds, so a successor's `--resume` is not refused while the harness pid that
|
|
637
|
-
* spawned it lives on. Each lock is removed
|
|
638
|
-
* holder took in the meantime is left alone.
|
|
851
|
+
* spawned it lives on. Each lock is re-checked and removed under its mutex, so
|
|
852
|
+
* a lock another holder took in the meantime is left alone (T14407).
|
|
639
853
|
*
|
|
640
854
|
* @param projectHash - Project hash.
|
|
641
855
|
* @param sessionId - The ended session.
|
|
@@ -653,11 +867,12 @@ export function releaseWorktreeTaskLocksForSession(projectHash, sessionId) {
|
|
|
653
867
|
const released = [];
|
|
654
868
|
for (const name of names) {
|
|
655
869
|
const taskId = name.slice(0, -'.lock'.length);
|
|
656
|
-
const
|
|
657
|
-
if (
|
|
870
|
+
const seen = readWorktreeTaskLock(projectHash, taskId);
|
|
871
|
+
if (seen === null || seen.sessionId !== sessionId)
|
|
658
872
|
continue;
|
|
659
|
-
|
|
660
|
-
|
|
873
|
+
const removed = releaseLockIf(resolveWorktreeTaskLockPath(projectHash, taskId), (r) => r.kind === 'record' && r.record.sessionId === sessionId);
|
|
874
|
+
if (removed?.kind === 'record')
|
|
875
|
+
released.push(removed.record);
|
|
661
876
|
}
|
|
662
877
|
return released;
|
|
663
878
|
}
|