fix: add deadman ceiling to withPlanningLock (M1/M2 R4-FIX asymmetry)

The M1/M2 lock fix (4903ee04) was asymmetric: acquireStateLock got a 60s deadman
ceiling (recovers a lock once its age crosses an absolute bound ABOVE the wait
budget) but withPlanningLock did not. The .lock body carries no startTime, so
_planningHolderVerifiedLive can only check pid liveness — it cannot detect pid
reuse. A false-alive holder (original holder crashed, pid recycled by an unrelated
live process) would therefore make withPlanningLock throw on every call with no
self-heal until the reused pid happens to die.

Mirror acquireStateLock: in the EEXIST branch, steal a verified-live holder anyway
once the lock ages past deadmanCeilingMs (60000 > lockTimeout 10000). mtime age is
measured from lock creation, so a stuck lock self-heals on a subsequent call.

Adds a clock+probe-seam regression test (false-alive holder past the ceiling IS
stolen). planning-workspace 13/13, clock-seam 34/34, locking suites green; lint clean.

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
This commit is contained in:
Dave
2026-06-21 10:17:28 -04:00
parent fc9bd70aff
commit b2602cc61d
2 changed files with 38 additions and 2 deletions

View File

@@ -289,4 +289,26 @@ describe('withPlanningLock PID-liveness staleness + EEXIST safety (audit M1+M2)'
assert.ok(caught, 'helper must surface a failure rather than silently force-stealing a live lock');
assert.notStrictEqual(caught.code, 'EEXIST', 'a raw EEXIST must never escape the lock helper (M2)');
});
test('R4-FIX: false-alive pid-reuse holder aged past the deadman ceiling IS stolen (self-heal)', () => {
const reusedPid = 7373;
fs.writeFileSync(lockPath, JSON.stringify({
pid: reusedPid,
cwd: tmpDir,
acquired: new Date().toISOString(),
}));
// Probe says the recorded pid is ALIVE — simulating pid-reuse: the original holder
// crashed but its pid was recycled by an unrelated live process. The .lock body has
// no startTime, so liveness alone cannot distinguish this from a genuine live holder.
planningWorkspaceDirect._setLockProbes({ isPidAlive: (pid) => pid === reusedPid });
// Lock mtime ≈ now (real); seed the fake clock ABOVE the 60 000 ms deadman ceiling so
// age = clock.now() - mtimeMs ≫ ceiling → the lock must be recovered despite "alive".
// Without the ceiling, withPlanningLock would throw on every call with no self-heal.
const clock = makeFakeClock(Date.now() + 120000);
const result = withPlanningLock(tmpDir, () => 'self-healed', clock);
assert.strictEqual(result, 'self-healed', 'a false-alive lock past the deadman ceiling must be stolen (no infinite block)');
assert.ok(!fs.existsSync(lockPath), 'lock must be released after the critical section completes');
});
});