@cleocode/worktree 2026.9.20 → 2026.9.22

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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Per-task worktree lock (T12506 · epic T12498 E-WORKTREE-MULTI-AGENT).
3
+ *
4
+ * `createWorktree` used to be check-then-act: when the task's worktree
5
+ * directory existed and `git status --porcelain` was empty it force-removed the
6
+ * worktree and ran `git branch -D task/<id>`. A LIVE agent's worktree looks
7
+ * clean whenever its only changes are gitignored (`.cleo/cleo.db`) or already
8
+ * committed, so a second `cleo orchestrate spawn <id>` deleted files out from
9
+ * under the running agent.
10
+ *
11
+ * This module is the mutual-exclusion half of the fix. Before any
12
+ * `git worktree add`, the spawner takes an atomic per-task lock:
13
+ *
14
+ * - **Location:** `<cleoHome>/locks/worktrees/<projectHash>/<taskId>.lock`
15
+ * (via `@cleocode/paths`), shared by every spawn of the task on this machine.
16
+ * - **Atomic publish:** the record is written to a private temp file and then
17
+ * hard-linked into place. `link(2)` fails with `EEXIST` when the lock already
18
+ * exists, so exactly one contender wins and a reader never sees a partially
19
+ * written record. Filesystems without hard links (exFAT, some network
20
+ * mounts) fall back to `open(O_CREAT|O_EXCL)` on the final path; a reader
21
+ * that meets a young, not-yet-parseable record waits instead of reclaiming.
22
+ * - **Every acquisition failure is a refusal.** An I/O error (EACCES, EROFS,
23
+ * ENOSPC, ...) surfaces as `E_WORKTREE_LOCKED` with `reason:
24
+ * 'lock-unavailable'` — never as a generic error a caller might answer with
25
+ * worktree cleanup.
26
+ * - **Holder identity:** session id, agent id, device id, owner pid plus the
27
+ * owner's `ps lstart` start time (rendered under `PS_STABLE_ENV`), host name
28
+ * and a heartbeat timestamp.
29
+ * - **Liveness:** a held lock is reclaimable only when its holder is provably
30
+ * dead (pid gone, or alive with a different start time = recycled pid). The
31
+ * heartbeat TTL applies ONLY to holders whose pid cannot be verified (another
32
+ * device, or no recorded start time); a verified live holder is never
33
+ * reclaimed on age. Anything else is `E_WORKTREE_LOCKED`.
34
+ * - **Re-entry:** the same caller (session id, agent id, owner pid and start
35
+ * time all equal) re-enters its own lock, so an orchestrator can retry.
36
+ * - **Reclaim:** serialised through a second O_EXCL "reclaim mutex" file so two
37
+ * contenders that both saw the same dead holder cannot both win; the winner
38
+ * re-reads the lock and only replaces it if it is still the record it judged.
39
+ *
40
+ * @module worktree-lock
41
+ * @task T12506
42
+ */
43
+ import { execFileSync } from 'node:child_process';
44
+ import { randomUUID } from 'node:crypto';
45
+ import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, } from 'node:fs';
46
+ import { hostname } from 'node:os';
47
+ import { dirname } from 'node:path';
48
+ import { BRANCH_LOCK_ERROR_CODES, ExitCode, PS_STABLE_ENV } from '@cleocode/contracts';
49
+ import { resolveStableDeviceIdPath, resolveWorktreeTaskLockPath } from '@cleocode/paths';
50
+ /** Default heartbeat TTL: 4 hours. Override with `CLEO_WORKTREE_LOCK_TTL_MS`. */
51
+ export const DEFAULT_WORKTREE_LOCK_TTL_MS = 4 * 60 * 60 * 1000;
52
+ /** Environment variable overriding {@link DEFAULT_WORKTREE_LOCK_TTL_MS}. */
53
+ export const WORKTREE_LOCK_TTL_ENV = 'CLEO_WORKTREE_LOCK_TTL_MS';
54
+ /** A reclaim mutex older than this is abandoned (its owner crashed mid-reclaim). */
55
+ const RECLAIM_MUTEX_STALE_MS = 30_000;
56
+ /** Attempts to obtain the reclaim mutex before re-evaluating the lock. */
57
+ const RECLAIM_ATTEMPTS = 50;
58
+ /** A record younger than this that does not parse is still being written (O_EXCL fallback). */
59
+ const UNREADABLE_GRACE_MS = 5_000;
60
+ /** Upper bound on acquisition passes (each pass wins, throws, waits or observes progress). */
61
+ const MAX_ACQUIRE_PASSES = 400;
62
+ /** `link(2)` errnos that mean "hard links unsupported here", not "lock held". */
63
+ const LINK_UNSUPPORTED = new Set(['ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'ENOSYS', 'EXDEV', 'EMLINK']);
64
+ /** Sleep between reclaim-mutex attempts. */
65
+ const RECLAIM_BACKOFF_MS = 20;
66
+ /**
67
+ * Resolve the heartbeat TTL: a positive integer `CLEO_WORKTREE_LOCK_TTL_MS`
68
+ * wins, anything else falls back to {@link DEFAULT_WORKTREE_LOCK_TTL_MS}.
69
+ *
70
+ * @param env - Environment to read (defaults to `process.env`).
71
+ * @returns TTL in milliseconds.
72
+ */
73
+ export function resolveWorktreeLockTtlMs(env = process.env) {
74
+ const raw = env[WORKTREE_LOCK_TTL_ENV];
75
+ if (raw === undefined)
76
+ return DEFAULT_WORKTREE_LOCK_TTL_MS;
77
+ const parsed = Number(raw);
78
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_WORKTREE_LOCK_TTL_MS;
79
+ }
80
+ /**
81
+ * Read a process's start time via `ps -o lstart=` with the stable env
82
+ * (UTC, C locale). Returns `null` on Windows or when `ps` fails.
83
+ *
84
+ * @param pid - Process id.
85
+ * @returns Whitespace-normalised `lstart` string, or `null`.
86
+ */
87
+ export function readProcessStartTime(pid) {
88
+ if (process.platform === 'win32' || !Number.isInteger(pid) || pid <= 0)
89
+ return null;
90
+ try {
91
+ const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
92
+ encoding: 'utf8',
93
+ timeout: 2_000,
94
+ stdio: ['ignore', 'pipe', 'ignore'],
95
+ env: { ...process.env, ...PS_STABLE_ENV },
96
+ }).trim();
97
+ return out === '' ? null : out.replace(/\s+/g, ' ');
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ /**
104
+ * Probe whether a pid is alive and, when it is, read its start time.
105
+ *
106
+ * `process.kill(pid, 0)` delivers no signal: `ESRCH` means no such process,
107
+ * `EPERM` means it exists but belongs to another user (still alive).
108
+ *
109
+ * @param pid - Process id.
110
+ * @returns Liveness snapshot.
111
+ */
112
+ export function probeProcess(pid) {
113
+ if (!Number.isInteger(pid) || pid <= 0)
114
+ return { alive: false, startedAt: null };
115
+ try {
116
+ process.kill(pid, 0);
117
+ }
118
+ catch (err) {
119
+ const code = err.code;
120
+ if (code !== 'EPERM')
121
+ return { alive: false, startedAt: null };
122
+ }
123
+ return { alive: true, startedAt: readProcessStartTime(pid) };
124
+ }
125
+ /**
126
+ * Read this device's persisted stable id (`<cleoHome>/device-id`), or `null`
127
+ * when core has not created it yet. Never creates it.
128
+ *
129
+ * @returns The device id, or `null`.
130
+ */
131
+ export function readStableDeviceId() {
132
+ try {
133
+ const raw = readFileSync(resolveStableDeviceIdPath(), 'utf-8').trim();
134
+ return raw === '' ? null : raw;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ /**
141
+ * Decide whether an existing lock's holder is live.
142
+ *
143
+ * - Same device (device ids equal; host names equal when either id is
144
+ * unknown): the pid is probed. Gone → `pid-gone`; alive with a different
145
+ * start time → `pid-recycled`; alive with the SAME start time → live and
146
+ * verified, whatever the heartbeat age (a verified live holder is never
147
+ * reclaimed on age — T12506 review).
148
+ * - Unverifiable (another device, or no start time on either side): only the
149
+ * heartbeat TTL can release it.
150
+ *
151
+ * @param record - The lock record on disk.
152
+ * @param opts - TTL, clock, probe and this device's id.
153
+ * @returns The assessment.
154
+ */
155
+ export function assessWorktreeLockHolder(record, opts) {
156
+ const sameDevice = record.deviceId !== null && opts.deviceId !== null
157
+ ? record.deviceId === opts.deviceId
158
+ : record.hostname === hostname();
159
+ if (sameDevice) {
160
+ const liveness = opts.probe(record.pid);
161
+ if (!liveness.alive)
162
+ return { live: false, reason: 'pid-gone' };
163
+ if (record.processStartedAt !== null && liveness.startedAt !== null) {
164
+ if (liveness.startedAt !== record.processStartedAt) {
165
+ return { live: false, reason: 'pid-recycled' };
166
+ }
167
+ return { live: true, verified: true };
168
+ }
169
+ }
170
+ const heartbeat = Date.parse(record.heartbeatAt);
171
+ if (!Number.isFinite(heartbeat) || opts.now - heartbeat > opts.ttlMs) {
172
+ return { live: false, reason: 'heartbeat-stale' };
173
+ }
174
+ return { live: true, verified: false };
175
+ }
176
+ /**
177
+ * True when `candidate` is the same caller as the lock's `holder`: session id
178
+ * and agent id both present and equal, and the same owner process (pid and
179
+ * start time). Only then may a spawn re-enter an existing lock.
180
+ *
181
+ * @param holder - Record on disk.
182
+ * @param candidate - Record the caller would publish.
183
+ * @returns Whether re-entry is allowed.
184
+ */
185
+ export function isSameLockCaller(holder, candidate) {
186
+ return (holder.sessionId !== null &&
187
+ holder.agentId !== null &&
188
+ holder.sessionId === candidate.sessionId &&
189
+ holder.agentId === candidate.agentId &&
190
+ holder.pid === candidate.pid &&
191
+ holder.processStartedAt !== null &&
192
+ holder.processStartedAt === candidate.processStartedAt);
193
+ }
194
+ /**
195
+ * Read and validate the lock record at `lockPath`.
196
+ *
197
+ * @param lockPath - Absolute lock file path.
198
+ * @param now - Current time (ms) used to age an unparseable file.
199
+ * @returns What is on disk.
200
+ */
201
+ function readLockAt(lockPath, now) {
202
+ let raw;
203
+ try {
204
+ raw = readFileSync(lockPath, 'utf-8');
205
+ }
206
+ catch (err) {
207
+ if (err.code === 'ENOENT')
208
+ return { kind: 'absent' };
209
+ throw err;
210
+ }
211
+ try {
212
+ const parsed = JSON.parse(raw);
213
+ if (parsed.schemaVersion === 1 &&
214
+ typeof parsed.token === 'string' &&
215
+ typeof parsed.pid === 'number' &&
216
+ typeof parsed.heartbeatAt === 'string') {
217
+ return { kind: 'record', record: parsed };
218
+ }
219
+ }
220
+ catch {
221
+ /* fall through */
222
+ }
223
+ let ageMs = Number.POSITIVE_INFINITY;
224
+ try {
225
+ ageMs = now - statSync(lockPath).mtimeMs;
226
+ }
227
+ catch {
228
+ /* vanished */
229
+ }
230
+ return { kind: 'unreadable', ageMs };
231
+ }
232
+ /**
233
+ * Read the current lock record for a task, if any.
234
+ *
235
+ * @param projectHash - Project hash.
236
+ * @param taskId - Task id.
237
+ * @returns The record, or `null` when no valid lock exists.
238
+ */
239
+ export function readWorktreeTaskLock(projectHash, taskId) {
240
+ try {
241
+ const read = readLockAt(resolveWorktreeTaskLockPath(projectHash, taskId), Date.now());
242
+ return read.kind === 'record' ? read.record : null;
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ }
248
+ /** Write `content` to `path` with `O_CREAT|O_EXCL` and fsync it. Throws EEXIST when taken. */
249
+ function writeExclusive(path, content) {
250
+ const fd = openSync(path, 'wx', 0o600);
251
+ try {
252
+ writeSync(fd, content);
253
+ fsyncSync(fd);
254
+ }
255
+ finally {
256
+ closeSync(fd);
257
+ }
258
+ }
259
+ /** Unlink `path`, ignoring a missing file. */
260
+ function unlinkQuiet(path) {
261
+ try {
262
+ unlinkSync(path);
263
+ }
264
+ catch {
265
+ /* already gone */
266
+ }
267
+ }
268
+ /** Serialise a record for disk. */
269
+ function serialise(record) {
270
+ return `${JSON.stringify(record, null, 2)}\n`;
271
+ }
272
+ /**
273
+ * Publish `record` at `lockPath` iff no lock exists.
274
+ *
275
+ * Primary path: temp file + `link(2)` (atomic, fails with EEXIST). When the
276
+ * filesystem has no hard links (exFAT → ENOTSUP/EPERM, ...), falls back to
277
+ * `O_EXCL` on the final path — still exclusive, and readers treat a young
278
+ * unparseable record as "being written".
279
+ *
280
+ * @returns true when this call created the lock; false when it already exists.
281
+ * @throws Any other I/O error (the caller turns it into a refusal).
282
+ */
283
+ function publishExclusive(lockPath, record) {
284
+ const tmp = `${lockPath}.${record.token}.tmp`;
285
+ writeExclusive(tmp, serialise(record));
286
+ let linkErr;
287
+ try {
288
+ linkSync(tmp, lockPath);
289
+ return true;
290
+ }
291
+ catch (err) {
292
+ linkErr = err;
293
+ }
294
+ finally {
295
+ unlinkQuiet(tmp);
296
+ }
297
+ if (linkErr.code === 'EEXIST')
298
+ return false;
299
+ if (!LINK_UNSUPPORTED.has(linkErr.code ?? ''))
300
+ throw linkErr;
301
+ try {
302
+ writeExclusive(lockPath, serialise(record));
303
+ return true;
304
+ }
305
+ catch (err) {
306
+ if (err.code === 'EEXIST')
307
+ return false;
308
+ throw err;
309
+ }
310
+ }
311
+ /** Block the thread for `ms` (bounded; used only while contending). */
312
+ function sleepSync(ms) {
313
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
314
+ }
315
+ /**
316
+ * Remove an abandoned mutex only if it is still the file we judged stale:
317
+ * rename it to a private name, then compare inodes. A mutex created in the
318
+ * meantime by a live contender is put back rather than deleted.
319
+ */
320
+ function breakStaleMutex(mutexPath, staleIno) {
321
+ const graveyard = `${mutexPath}.${randomUUID()}.stale`;
322
+ try {
323
+ renameSync(mutexPath, graveyard);
324
+ }
325
+ catch {
326
+ return; // already gone
327
+ }
328
+ let ino = -1;
329
+ try {
330
+ ino = statSync(graveyard).ino;
331
+ }
332
+ catch {
333
+ return;
334
+ }
335
+ if (ino === staleIno) {
336
+ unlinkQuiet(graveyard);
337
+ return;
338
+ }
339
+ try {
340
+ linkSync(graveyard, mutexPath); // restore the live contender's mutex
341
+ }
342
+ catch {
343
+ try {
344
+ renameSync(graveyard, mutexPath);
345
+ }
346
+ catch {
347
+ /* best effort */
348
+ }
349
+ }
350
+ unlinkQuiet(graveyard);
351
+ }
352
+ /**
353
+ * Take the per-lock mutex (`<lock>.reclaim`, O_EXCL, content = our token). It
354
+ * serialises every non-create transition of the lock file (reclaim, re-entry,
355
+ * heartbeat). A mutex older than {@link RECLAIM_MUTEX_STALE_MS} belongs to a
356
+ * crashed holder and is broken by inode.
357
+ *
358
+ * @returns A release function (removes the mutex only if it is still ours),
359
+ * or `null` when the mutex stayed busy.
360
+ */
361
+ function takeLockMutex(lockPath, now) {
362
+ const mutexPath = `${lockPath}.reclaim`;
363
+ const token = randomUUID();
364
+ for (let attempt = 0; attempt < RECLAIM_ATTEMPTS; attempt++) {
365
+ try {
366
+ writeExclusive(mutexPath, token);
367
+ return () => {
368
+ try {
369
+ if (readFileSync(mutexPath, 'utf-8') === token)
370
+ unlinkSync(mutexPath);
371
+ }
372
+ catch {
373
+ /* already gone */
374
+ }
375
+ };
376
+ }
377
+ catch (err) {
378
+ if (err.code !== 'EEXIST')
379
+ throw err;
380
+ try {
381
+ const st = statSync(mutexPath);
382
+ if (now() - st.mtimeMs > RECLAIM_MUTEX_STALE_MS)
383
+ breakStaleMutex(mutexPath, st.ino);
384
+ }
385
+ catch {
386
+ /* vanished between open and stat — retry */
387
+ }
388
+ sleepSync(RECLAIM_BACKOFF_MS);
389
+ }
390
+ }
391
+ return null;
392
+ }
393
+ /** Build the typed E_WORKTREE_LOCKED error naming the holder. */
394
+ function lockedError(taskId, lockPath, holder) {
395
+ const who = [
396
+ holder.sessionId ? `session ${holder.sessionId}` : null,
397
+ holder.agentId ? `agent ${holder.agentId}` : null,
398
+ `pid ${holder.pid}${holder.processStartedAt ? ` (started ${holder.processStartedAt} UTC)` : ''}`,
399
+ holder.deviceId ? `device ${holder.deviceId}` : null,
400
+ `host ${holder.hostname}`,
401
+ ]
402
+ .filter((part) => part !== null)
403
+ .join(', ');
404
+ const fix = `The worktree for ${taskId} belongs to a live holder (${who}). The same orchestrator ` +
405
+ `(same session, agent and owner process) re-enters automatically. Otherwise wait for ` +
406
+ `that holder to finish; if it is abandoned, stop process ${holder.pid} — a dead ` +
407
+ `holder's lock is reclaimed on the next spawn and its worktree is re-attached, never ` +
408
+ `removed. Lock file: ${lockPath}`;
409
+ return Object.assign(new Error(`${BRANCH_LOCK_ERROR_CODES.E_WORKTREE_LOCKED}: the worktree for task ${taskId} is locked by ` +
410
+ `${who} since ${holder.acquiredAt}. Refusing to re-provision a live worktree.`), {
411
+ code: BRANCH_LOCK_ERROR_CODES.E_WORKTREE_LOCKED,
412
+ exitCode: ExitCode.WORKTREE_LOCKED,
413
+ holder,
414
+ reason: 'held',
415
+ lockPath,
416
+ fix,
417
+ });
418
+ }
419
+ /** Wrap an I/O failure while taking the lock as a refusal (never a cleanup trigger). */
420
+ function unavailableError(taskId, lockPath, cause) {
421
+ const detail = cause instanceof Error
422
+ ? `${cause.code ?? cause.name}: ${cause.message}`
423
+ : String(cause);
424
+ return Object.assign(new Error(`${BRANCH_LOCK_ERROR_CODES.E_WORKTREE_LOCKED}: could not take the worktree lock for task ` +
425
+ `${taskId} (${detail}). Refusing to provision without it; nothing was removed.`), {
426
+ code: BRANCH_LOCK_ERROR_CODES.E_WORKTREE_LOCKED,
427
+ exitCode: ExitCode.WORKTREE_LOCKED,
428
+ holder: null,
429
+ reason: 'lock-unavailable',
430
+ lockPath,
431
+ fix: `Make ${dirname(lockPath)} writable (check permissions, free space and the CLEO_HOME filesystem), then retry the spawn.`,
432
+ cause,
433
+ });
434
+ }
435
+ /**
436
+ * Type guard for the error thrown by {@link acquireWorktreeTaskLock}.
437
+ *
438
+ * @param err - Any thrown value.
439
+ * @returns True when `err` is an `E_WORKTREE_LOCKED` error.
440
+ */
441
+ export function isWorktreeLockedError(err) {
442
+ return (err instanceof Error &&
443
+ err.code === BRANCH_LOCK_ERROR_CODES.E_WORKTREE_LOCKED);
444
+ }
445
+ /**
446
+ * Atomically acquire the per-task worktree lock: create it, re-enter it (same
447
+ * caller), or reclaim it from a provably dead / unverifiable-and-stale holder.
448
+ *
449
+ * @param options - Task, holder identity, TTL and test seams.
450
+ * @returns How the lock was obtained plus the record now on disk.
451
+ * @throws {@link WorktreeLockedError} (`E_WORKTREE_LOCKED`) when a live holder
452
+ * owns the lock OR the lock could not be taken at all. Every failure of this
453
+ * function is a refusal: callers must never answer it with cleanup.
454
+ */
455
+ export function acquireWorktreeTaskLock(options) {
456
+ const lockPath = resolveWorktreeTaskLockPath(options.projectHash, options.taskId);
457
+ try {
458
+ return acquireUnchecked(options, lockPath);
459
+ }
460
+ catch (err) {
461
+ if (isWorktreeLockedError(err))
462
+ throw err;
463
+ throw unavailableError(options.taskId, lockPath, err);
464
+ }
465
+ }
466
+ /** {@link acquireWorktreeTaskLock} without the refusal wrapper. */
467
+ function acquireUnchecked(options, lockPath) {
468
+ const { projectHash, taskId } = options;
469
+ const holder = options.holder ?? {};
470
+ const now = options.now ?? Date.now;
471
+ const probe = options.probe ?? probeProcess;
472
+ const ttlMs = options.ttlMs ?? resolveWorktreeLockTtlMs();
473
+ mkdirSync(dirname(lockPath), { recursive: true });
474
+ const pid = holder.pid ?? process.pid;
475
+ const processStartedAt = holder.processStartedAt !== undefined ? holder.processStartedAt : readProcessStartTime(pid);
476
+ const deviceId = holder.deviceId !== undefined && holder.deviceId !== null
477
+ ? holder.deviceId
478
+ : options.deviceId !== undefined
479
+ ? options.deviceId
480
+ : readStableDeviceId();
481
+ const stamp = new Date(now()).toISOString();
482
+ const record = {
483
+ schemaVersion: 1,
484
+ token: randomUUID(),
485
+ taskId,
486
+ projectHash,
487
+ sessionId: holder.sessionId ?? null,
488
+ agentId: holder.agentId ?? null,
489
+ deviceId,
490
+ pid,
491
+ processStartedAt,
492
+ hostname: hostname(),
493
+ acquiredAt: stamp,
494
+ heartbeatAt: stamp,
495
+ };
496
+ for (let pass = 0; pass < MAX_ACQUIRE_PASSES; pass++) {
497
+ if (publishExclusive(lockPath, record))
498
+ return { status: 'acquired', lockPath, record };
499
+ const existing = readLockAt(lockPath, now());
500
+ if (existing.kind === 'absent')
501
+ continue; // released between create and read — retry
502
+ let status;
503
+ let reason;
504
+ if (existing.kind === 'unreadable') {
505
+ if (existing.ageMs < UNREADABLE_GRACE_MS) {
506
+ sleepSync(RECLAIM_BACKOFF_MS); // O_EXCL fallback writer mid-write
507
+ continue;
508
+ }
509
+ status = 'reclaimed';
510
+ reason = 'unreadable';
511
+ }
512
+ else if (isSameLockCaller(existing.record, record)) {
513
+ status = 'reentered';
514
+ }
515
+ else {
516
+ const verdict = assessWorktreeLockHolder(existing.record, {
517
+ ttlMs,
518
+ now: now(),
519
+ probe,
520
+ deviceId,
521
+ });
522
+ if (verdict.live)
523
+ throw lockedError(taskId, lockPath, existing.record);
524
+ status = 'reclaimed';
525
+ reason = verdict.reason;
526
+ }
527
+ const release = takeLockMutex(lockPath, now);
528
+ if (release === null)
529
+ continue; // another contender is transitioning it — re-evaluate
530
+ try {
531
+ // Re-read under the mutex: only replace the exact record we judged.
532
+ const current = readLockAt(lockPath, now());
533
+ const same = existing.kind === 'unreadable'
534
+ ? current.kind === 'unreadable'
535
+ : current.kind === 'record' && current.record.token === existing.record.token;
536
+ if (!same)
537
+ continue;
538
+ unlinkQuiet(lockPath);
539
+ if (publishExclusive(lockPath, record)) {
540
+ return {
541
+ status,
542
+ lockPath,
543
+ record,
544
+ ...(existing.kind === 'record' ? { reclaimedFrom: existing.record } : {}),
545
+ ...(status === 'reclaimed' && reason ? { reclaimReason: reason } : {}),
546
+ };
547
+ }
548
+ }
549
+ finally {
550
+ release();
551
+ }
552
+ }
553
+ const final = readLockAt(lockPath, now());
554
+ if (final.kind === 'record')
555
+ throw lockedError(taskId, lockPath, final.record);
556
+ throw new Error(`lock for ${taskId} stayed contended for ${MAX_ACQUIRE_PASSES} passes`);
557
+ }
558
+ /**
559
+ * Refresh the holder's heartbeat. Only the holder that owns `token` may beat,
560
+ * and the rewrite happens under the lock mutex after re-checking the token, so
561
+ * it can never clobber a lock another contender reclaimed in the meantime.
562
+ *
563
+ * @param projectHash - Project hash.
564
+ * @param taskId - Task id.
565
+ * @param token - Token of the acquisition to refresh.
566
+ * @param now - Clock (default `Date.now`).
567
+ * @returns The refreshed record, or `null` when `token` no longer holds the lock.
568
+ */
569
+ export function heartbeatWorktreeTaskLock(projectHash, taskId, token, now = Date.now) {
570
+ const lockPath = resolveWorktreeTaskLockPath(projectHash, taskId);
571
+ const release = takeLockMutex(lockPath, now);
572
+ if (release === null)
573
+ return null;
574
+ try {
575
+ const current = readLockAt(lockPath, now());
576
+ if (current.kind !== 'record' || current.record.token !== token)
577
+ return null;
578
+ const next = {
579
+ ...current.record,
580
+ heartbeatAt: new Date(now()).toISOString(),
581
+ };
582
+ const tmp = `${lockPath}.${token}.beat.tmp`;
583
+ unlinkQuiet(tmp);
584
+ writeExclusive(tmp, serialise(next));
585
+ renameSync(tmp, lockPath);
586
+ return next;
587
+ }
588
+ catch {
589
+ return null;
590
+ }
591
+ finally {
592
+ release();
593
+ }
594
+ }
595
+ /**
596
+ * Release the per-task lock. With `token`, only that acquisition's lock is
597
+ * removed (a lock since reclaimed or re-entered by someone else is left alone).
598
+ *
599
+ * @param projectHash - Project hash.
600
+ * @param taskId - Task id.
601
+ * @param token - Optional token of the acquisition to release.
602
+ * @returns True when a lock file was removed.
603
+ */
604
+ export function releaseWorktreeTaskLock(projectHash, taskId, token) {
605
+ const lockPath = resolveWorktreeTaskLockPath(projectHash, taskId);
606
+ try {
607
+ if (token !== undefined) {
608
+ const current = readLockAt(lockPath, Date.now());
609
+ if (current.kind !== 'record' || current.record.token !== token)
610
+ return false;
611
+ }
612
+ unlinkSync(lockPath);
613
+ return true;
614
+ }
615
+ catch {
616
+ return false;
617
+ }
618
+ }
619
+ //# sourceMappingURL=worktree-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktree-lock.js","sourceRoot":"","sources":["../src/worktree-lock.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"worktree-prune.d.ts","sourceRoot":"","sources":["../src/worktree-prune.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAKH,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAEvF,OAAO,EAEL,cAAc,IAAI,SAAS,EAE5B,MAAM,mBAAmB,CAAC;AAI3B;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,oBAAoB,CA4DnF;AAmZD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,MAAM,EACf,iBAAiB,EAAE,MAAM,GACxB,UAAU,CAAC,OAAO,SAAS,CAAC,GAAG,IAAI,CAMrC"}
1
+ {"version":3,"file":"worktree-prune.d.ts","sourceRoot":"","sources":["../src/worktree-prune.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAKH,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAEvF,OAAO,EAEL,cAAc,IAAI,SAAS,EAE5B,MAAM,mBAAmB,CAAC;AAK3B;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,oBAAoB,CA4DnF;AAqZD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,MAAM,EACf,iBAAiB,EAAE,MAAM,GACxB,UAAU,CAAC,OAAO,SAAS,CAAC,GAAG,IAAI,CAMrC"}
@@ -30,6 +30,7 @@ import { getGitRoot, gitSilent } from './git.js';
30
30
  import { destroyWorktree as napiDestroyWorktree, pruneWorktrees as napiPrune, removeDir as napiRemoveDir, } from './napi-binding.js';
31
31
  import { computeProjectHash, resolveWorktreeRootForHash } from './paths.js';
32
32
  import { appendWorktreeAuditLog, removeWorktreeFromSentinelIndex } from './worktree-audit.js';
33
+ import { releaseWorktreeTaskLock } from './worktree-lock.js';
33
34
  /**
34
35
  * Prune orphaned agent worktrees for a project.
35
36
  *
@@ -421,6 +422,8 @@ function pruneSingleEntry(decision, gitRoot, projectRoot, removed, quarantined,
421
422
  success: true,
422
423
  });
423
424
  removeWorktreeFromSentinelIndex(gitRoot, taskId);
425
+ // T12506: a pruned worktree's per-task lock guards nothing — free it.
426
+ releaseWorktreeTaskLock(computeProjectHash(projectRoot), taskId);
424
427
  return;
425
428
  }
426
429
  const finalError = errorMessage ?? 'napi removeDir failed';