@cleocode/worktree 2026.10.27 → 2026.11.1-canary.10

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.
Files changed (38) hide show
  1. package/dist/.source-stamp.json +4 -0
  2. package/dist/git.d.ts +0 -13
  3. package/dist/git.d.ts.map +1 -1
  4. package/dist/git.js +0 -18
  5. package/dist/git.js.map +1 -1
  6. package/dist/index.d.ts +12 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +13 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/worktree-create.d.ts +32 -1
  11. package/dist/worktree-create.d.ts.map +1 -1
  12. package/dist/worktree-create.js +153 -34
  13. package/dist/worktree-create.js.map +1 -1
  14. package/dist/worktree-destroy.d.ts +5 -5
  15. package/dist/worktree-destroy.d.ts.map +1 -1
  16. package/dist/worktree-destroy.js +79 -120
  17. package/dist/worktree-destroy.js.map +1 -1
  18. package/dist/worktree-dispose.d.ts +193 -0
  19. package/dist/worktree-dispose.d.ts.map +1 -0
  20. package/dist/worktree-dispose.js +608 -0
  21. package/dist/worktree-dispose.js.map +1 -0
  22. package/dist/worktree-locate.d.ts +2 -0
  23. package/dist/worktree-locate.d.ts.map +1 -1
  24. package/dist/worktree-locate.js +12 -1
  25. package/dist/worktree-locate.js.map +1 -1
  26. package/dist/worktree-lock.d.ts +56 -7
  27. package/dist/worktree-lock.d.ts.map +1 -1
  28. package/dist/worktree-lock.js +315 -100
  29. package/dist/worktree-lock.js.map +1 -1
  30. package/dist/worktree-prune.d.ts +1 -3
  31. package/dist/worktree-prune.d.ts.map +1 -1
  32. package/dist/worktree-prune.js +20 -241
  33. package/dist/worktree-prune.js.map +1 -1
  34. package/native/worktree-napi.darwin-arm64.node +0 -0
  35. package/native/worktree-napi.linux-arm64-gnu.node +0 -0
  36. package/native/worktree-napi.linux-x64-gnu.node +0 -0
  37. package/native/worktree-napi.win32-x64-msvc.node +0 -0
  38. package/package.json +4 -4
@@ -56,14 +56,22 @@ export function parseWorktreePorcelain(raw) {
56
56
  let branch = null;
57
57
  let locked = false;
58
58
  let bare = false;
59
+ let head = null;
59
60
  /** Flush the record under construction, if any. */
60
61
  const flush = () => {
61
62
  if (path !== null)
62
- out.push({ path, branch, locked, ...(bare ? { bare: true } : {}) });
63
+ out.push({
64
+ path,
65
+ branch,
66
+ locked,
67
+ ...(head ? { head } : {}),
68
+ ...(bare ? { bare: true } : {}),
69
+ });
63
70
  path = null;
64
71
  branch = null;
65
72
  locked = false;
66
73
  bare = false;
74
+ head = null;
67
75
  };
68
76
  for (const line of raw.split('\n')) {
69
77
  const trimmed = line.trimEnd();
@@ -75,6 +83,9 @@ export function parseWorktreePorcelain(raw) {
75
83
  flush();
76
84
  path = trimmed.slice('worktree '.length);
77
85
  }
86
+ else if (trimmed.startsWith('HEAD ')) {
87
+ head = trimmed.slice('HEAD '.length);
88
+ }
78
89
  else if (trimmed.startsWith('branch ')) {
79
90
  branch = trimmed.slice('branch '.length).replace(/^refs\/heads\//, '');
80
91
  }
@@ -1 +1 @@
1
- {"version":3,"file":"worktree-locate.js","sourceRoot":"","sources":["../src/worktree-locate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"worktree-locate.js","sourceRoot":"","sources":["../src/worktree-locate.ts"],"names":[],"mappings":"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"}
@@ -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
- * - **Reclaim:** serialised through a second O_EXCL "reclaim mutex" file so two
39
- * contenders that both saw the same dead holder cannot both win; the winner
40
- * re-reads the lock and only replaces it if it is still the record it judged.
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 acquisition's lock is
211
- * removed (a lock since reclaimed or re-entered by someone else is left alone).
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 by its own token, so a lock another
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":"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"}
1
+ {"version":3,"file":"worktree-lock.d.ts","sourceRoot":"","sources":["../src/worktree-lock.ts"],"names":[],"mappings":"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"}
@@ -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
- * - **Reclaim:** serialised through a second O_EXCL "reclaim mutex" file so two
39
- * contenders that both saw the same dead holder cannot both win; the winner
40
- * re-reads the lock and only replaces it if it is still the record it judged.
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
- * Remove an abandoned mutex only if it is still the file we judged stale:
330
- * rename it to a private name, then compare inodes. A mutex created in the
331
- * meantime by a live contender is put back rather than deleted.
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 breakStaleMutex(mutexPath, staleIno) {
334
- const graveyard = `${mutexPath}.${randomUUID()}.stale`;
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
- renameSync(mutexPath, graveyard);
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
- return; // already gone
357
+ /* a bare-token mutex from an older writer */
340
358
  }
341
- let ino = -1;
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
- ino = statSync(graveyard).ino;
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
- linkSync(graveyard, mutexPath); // restore the live contender's mutex
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
- try {
357
- renameSync(graveyard, mutexPath);
358
- }
359
- catch {
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, content = our token). It
367
- * serialises every non-create transition of the lock file (reclaim, re-entry,
368
- * heartbeat). A mutex older than {@link RECLAIM_MUTEX_STALE_MS} belongs to a
369
- * crashed holder and is broken by inode.
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
- const token = randomUUID();
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, token);
419
+ writeExclusive(mutexPath, content);
380
420
  return () => {
381
421
  try {
382
- if (readFileSync(mutexPath, 'utf-8') === token)
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
- try {
394
- const st = statSync(mutexPath);
395
- if (now() - st.mtimeMs > RECLAIM_MUTEX_STALE_MS)
396
- breakStaleMutex(mutexPath, st.ino);
397
- }
398
- catch {
399
- /* vanished between open and stat — retry */
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 (publishExclusive(lockPath, record))
511
- return { status: 'acquired', lockPath, record };
512
- const existing = readLockAt(lockPath, now());
513
- if (existing.kind === 'absent')
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
- continue; // another contender is transitioning it — re-evaluate
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
- // Re-read under the mutex: only replace the exact record we judged.
546
- const current = readLockAt(lockPath, now());
547
- const same = existing.kind === 'unreadable'
548
- ? current.kind === 'unreadable'
549
- : current.kind === 'record' && current.record.token === existing.record.token;
550
- if (!same)
551
- continue;
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 acquisition's lock is
611
- * removed (a lock since reclaimed or re-entered by someone else is left alone).
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
- try {
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 by its own token, so a lock another
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 record = readWorktreeTaskLock(projectHash, taskId);
657
- if (record === null || record.sessionId !== sessionId)
870
+ const seen = readWorktreeTaskLock(projectHash, taskId);
871
+ if (seen === null || seen.sessionId !== sessionId)
658
872
  continue;
659
- if (releaseWorktreeTaskLock(projectHash, taskId, record.token))
660
- released.push(record);
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
  }