@yemi33/minions 0.1.2426 → 0.1.2428

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.
@@ -48,7 +48,9 @@ release .lock file
48
48
  Key properties:
49
49
  - **Synchronous blocking** — `withFileLock` spins with `sleepMs(25)` until lock acquired or 5s timeout (source: `engine/shared.js` `withFileLock` ~L1329)
50
50
  - **Whole-file granularity** — updating one field in one work item rewrites all 180 items (370 KB)
51
- - **Stale lock recovery** — locks older than 5 min (`LOCK_STALE_MS = 300_000`) are force-removed; holders that recorded a `{pid, ts}` payload are kept alive past the threshold while `process.kill(pid, 0)` succeeds, with a hard last-resort cap at 5×LOCK_STALE_MS (source: `engine/shared.js`, P-b7d4e8f2)
51
+ - **Stale lock recovery** — locks older than 5 min (`LOCK_STALE_MS = 300_000`) are force-removed; holders that recorded a `{pid, ts, token}` payload are kept alive past the threshold while `process.kill(pid, 0)` succeeds, with a hard last-resort cap at 5×LOCK_STALE_MS (source: `engine/shared.js`, P-b7d4e8f2)
52
+ - **Identity-checked reap and release** — every acquisition stamps a unique `token` into the lock payload. Reaping never unlinks the lock path directly: the reaper re-observes the same token after a short grace delay, takes custody with an atomic `rename`, and verifies the quarantined file is the acquisition it judged stale (restoring it otherwise). Release likewise unlinks only while the on-disk token is still ours. Removing a lock by path alone is a TOCTOU that lets a second writer into the critical section and silently loses one update (source: `engine/shared.js` `withFileLock`, W-ms2nbtkj00016ffc)
53
+ - **Compromised sections fail loudly** — if the lock is gone or re-owned when the critical section ends, `withFileLock` throws `ELOCKCOMPROMISED`. `mutateJsonFileLocked` / `mutateTextFileLocked` first re-apply the mutation against the current on-disk state (up to `LOCK_COMPROMISE_REDOS`) when their own write did not survive, so a raced update is recovered rather than dropped
52
54
  - **Read caching** — only `dispatch.json` has a 2s TTL cache (source: `engine/queries.js`)
53
55
 
54
56
  ### 1.3 Read vs Write Ratio
package/engine/shared.js CHANGED
@@ -972,7 +972,16 @@ function mutateTextFileLocked(filePath, mutateFn, {
972
972
  safeWrite(filePath, finalText);
973
973
  }
974
974
  return finalText;
975
- }, { retries, retryBackoffMs });
975
+ }, {
976
+ retries,
977
+ retryBackoffMs,
978
+ compromiseRedos: LOCK_COMPROMISE_REDOS,
979
+ // Our lock was stolen mid-section, so another writer shared it. Redo the
980
+ // read-modify-write against the current on-disk state only if our own write
981
+ // did not survive — otherwise the update is intact and a redo would just
982
+ // re-apply it.
983
+ onCompromised: (written) => (safeRead(filePath) === written ? 'accept' : 'redo'),
984
+ });
976
985
  }
977
986
 
978
987
  function safeUnlink(p) {
@@ -1406,14 +1415,73 @@ function pruneCrashDiagnosticsReports(config) {
1406
1415
  return removed;
1407
1416
  }
1408
1417
 
1418
+ /**
1419
+ * Read a lock file's holder payload.
1420
+ *
1421
+ * Returns `{ missing, unreadable, payload }` so callers can tell "the lock is
1422
+ * gone" (definitive — someone removed it) from "we couldn't read it right now"
1423
+ * (transient Windows sharing violation) from "it's there but has no parseable
1424
+ * payload" (legacy lock, or the sub-millisecond window between create and
1425
+ * payload write).
1426
+ */
1427
+ function _readLockPayload(lockPath) {
1428
+ let raw;
1429
+ try {
1430
+ raw = fs.readFileSync(lockPath, 'utf8');
1431
+ } catch (err) {
1432
+ if (err && err.code === 'ENOENT') return { missing: true, unreadable: false, payload: null };
1433
+ return { missing: false, unreadable: true, payload: null };
1434
+ }
1435
+ try {
1436
+ const parsed = JSON.parse(raw);
1437
+ if (parsed && typeof parsed === 'object') return { missing: false, unreadable: false, payload: parsed };
1438
+ } catch { /* empty (create/write window) or legacy lock → no payload */ }
1439
+ return { missing: false, unreadable: false, payload: null };
1440
+ }
1441
+
1442
+ /**
1443
+ * Does `payload` describe the same lock acquisition we observed and judged
1444
+ * stale? `observed` is the payload read during the EEXIST inspection.
1445
+ *
1446
+ * Tokens are the authoritative identity. Locks written by an older engine (or
1447
+ * caught in the create/write window) have no token, so fall back to pid — and
1448
+ * treat "still no payload" as "still the same anonymous lock".
1449
+ */
1450
+ function _isSameLockAcquisition(observed, payload) {
1451
+ if (observed && observed.token) return !!(payload && payload.token === observed.token);
1452
+ if (payload && payload.token) return false; // a fresh tokened lock replaced it
1453
+ if (observed && Number.isFinite(observed.pid)) return !!(payload && payload.pid === observed.pid);
1454
+ return payload === null;
1455
+ }
1456
+
1457
+ // Bounded redo budget for a critical section whose lock was compromised (stolen
1458
+ // by a reaper or otherwise re-owned mid-section). Read-modify-write callers
1459
+ // re-run against the current on-disk state so a raced write is re-applied
1460
+ // instead of being silently lost; see mutateJsonFileLocked/mutateTextFileLocked.
1461
+ const LOCK_COMPROMISE_REDOS = 3;
1462
+
1463
+ /**
1464
+ * Run `fn` under an exclusive file lock at `lockPath`.
1465
+ *
1466
+ * `onCompromised(result)` is consulted when the lock was stolen mid-section
1467
+ * (a reaper removed it, or it is now owned by a different token) — i.e. another
1468
+ * holder shared the critical section. Return `'accept'` to keep `result`, or
1469
+ * `'redo'` to re-run `fn` under a fresh lock (bounded by `compromiseRedos`).
1470
+ * Without the hook a compromised section throws `ELOCKCOMPROMISED` rather than
1471
+ * returning a value that may have been produced from, or clobbered by, a raced
1472
+ * snapshot.
1473
+ */
1409
1474
  function withFileLock(lockPath, fn, {
1410
1475
  timeoutMs = 5000,
1411
1476
  retryDelayMs = 25,
1412
1477
  retries = 0,
1413
- retryBackoffMs = 1000
1478
+ retryBackoffMs = 1000,
1479
+ compromiseRedos = 0,
1480
+ onCompromised = null
1414
1481
  } = {}) {
1415
1482
  let lastErr = null;
1416
1483
  const maxAttempts = 1 + Math.max(0, retries);
1484
+ let redosLeft = Math.max(0, compromiseRedos);
1417
1485
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1418
1486
  if (attempt > 0) {
1419
1487
  // Exponential backoff between retry attempts: retryBackoffMs * 2^(attempt-1)
@@ -1421,19 +1489,32 @@ function withFileLock(lockPath, fn, {
1421
1489
  sleepMs(backoff);
1422
1490
  }
1423
1491
  const start = Date.now();
1424
- let fd = null;
1492
+ let acquired = false;
1493
+ // Per-acquisition identity. The reaper and the release path both compare
1494
+ // against this so neither ever destroys a lock it does not own — an unlink
1495
+ // keyed only on the path is a TOCTOU that lets two holders into the
1496
+ // critical section at once (W-ms2nbtkj00016ffc).
1497
+ const token = `${process.pid}-${uid()}`;
1498
+ let tokenPersisted = false;
1425
1499
  while (Date.now() - start < timeoutMs) {
1426
1500
  try {
1427
1501
  const dir = path.dirname(lockPath);
1428
1502
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1429
- fd = fs.openSync(lockPath, 'wx');
1503
+ const fd = fs.openSync(lockPath, 'wx');
1430
1504
  // P-b7d4e8f2 — record holder identity so the stale-lock reaper can
1431
1505
  // distinguish a still-alive slow holder from a crashed one. Best-effort:
1432
1506
  // the lock's existence (not its contents) provides mutual exclusion, so
1433
- // a write failure here must NOT abort acquisition.
1507
+ // a write failure here must NOT abort acquisition (it only downgrades us
1508
+ // to the legacy pid-keyed release path).
1434
1509
  try {
1435
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
1510
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now(), token }));
1511
+ tokenPersisted = true;
1436
1512
  } catch { /* payload is advisory; lock semantics unaffected */ }
1513
+ // Close immediately: an open handle keeps the file in Windows'
1514
+ // "pending delete" limbo after unlink, which makes waiters keep seeing
1515
+ // (and repeatedly trying to reap) a lock that is already released.
1516
+ try { fs.closeSync(fd); } catch { /* handle is not load-bearing */ }
1517
+ acquired = true;
1437
1518
  break;
1438
1519
  } catch (err) {
1439
1520
  // EEXIST is the cross-platform exclusive-create collision.
@@ -1453,29 +1534,60 @@ function withFileLock(lockPath, fn, {
1453
1534
  try {
1454
1535
  const stat = fs.statSync(lockPath);
1455
1536
  const mtimeAge = Date.now() - stat.mtimeMs;
1456
- let holderPid = null;
1457
- try {
1458
- const raw = fs.readFileSync(lockPath, 'utf8');
1459
- const parsed = JSON.parse(raw);
1460
- if (parsed && Number.isFinite(parsed.pid) && parsed.pid > 0) {
1461
- holderPid = parsed.pid;
1462
- }
1463
- } catch { /* legacy/empty/corrupt lock → mtime-only fallback */ }
1537
+ const observed = _readLockPayload(lockPath).payload;
1538
+ const holderPid = observed && Number.isFinite(observed.pid) && observed.pid > 0
1539
+ ? observed.pid
1540
+ : null;
1464
1541
 
1465
1542
  const shouldReap = holderPid !== null
1466
1543
  ? !processUtils.isPidAlive(holderPid) || mtimeAge > LOCK_STALE_MS * 5
1467
1544
  : mtimeAge > LOCK_STALE_MS;
1468
1545
 
1469
1546
  if (shouldReap) {
1547
+ // Do not race a lock that is simply being released. Require the
1548
+ // SAME acquisition (identified by its token) to still be present
1549
+ // after a short grace delay: a normally-releasing holder's lock
1550
+ // disappears inside that window, and a fresh holder's lock carries
1551
+ // a different token. Without this, the reaper routinely removed a
1552
+ // brand-new live holder's lock in the microseconds after the
1553
+ // previous holder exited (W-ms2nbtkj00016ffc).
1554
+ sleepMs(retryDelayMs);
1555
+ const recheck = _readLockPayload(lockPath);
1556
+ if (recheck.missing || recheck.unreadable
1557
+ || !_isSameLockAcquisition(observed, recheck.payload)) {
1558
+ continue; // resolved itself — back to normal contention
1559
+ }
1560
+ // Reap by IDENTITY, never by path. Renaming is atomic, so exactly
1561
+ // one racer takes custody and can then verify that what it took is
1562
+ // the acquisition it judged stale.
1563
+ const reapPath = `${lockPath}.reap-${process.pid}-${uid()}`;
1564
+ let reaped = false;
1470
1565
  try {
1471
- fs.unlinkSync(lockPath);
1472
- } catch (unlinkErr) {
1473
- // ENOENT: another process deleted the lock between stat and unlink — safe to retry.
1474
- // EPERM on Windows: file is in 'pending delete' state from a concurrent
1475
- // reaper the OS will finalize the delete shortly; retry will succeed.
1476
- const tolerable = unlinkErr.code === 'ENOENT' ||
1477
- (process.platform === 'win32' && unlinkErr.code === 'EPERM');
1478
- if (!tolerable) throw unlinkErr;
1566
+ fs.renameSync(lockPath, reapPath);
1567
+ reaped = true;
1568
+ } catch (reapErr) {
1569
+ // ENOENT: another reaper/holder already removed it retry.
1570
+ // EPERM on Windows: pending-delete state retry.
1571
+ const tolerable = reapErr.code === 'ENOENT' ||
1572
+ (process.platform === 'win32' && reapErr.code === 'EPERM');
1573
+ if (!tolerable) throw reapErr;
1574
+ }
1575
+ if (reaped) {
1576
+ const quarantined = _readLockPayload(reapPath).payload;
1577
+ if (_isSameLockAcquisition(observed, quarantined)) {
1578
+ try { fs.unlinkSync(reapPath); } catch { /* already gone */ }
1579
+ } else {
1580
+ // We took custody of a lock we never judged stale — a live
1581
+ // holder replaced the stale one in the last instant. Put it
1582
+ // back and keep waiting. linkSync fails if a newer lock already
1583
+ // exists, which is the correct outcome (that lock is the live
1584
+ // one); the displaced holder then detects the compromise at
1585
+ // release rather than losing its write silently.
1586
+ try { fs.linkSync(reapPath, lockPath); } catch { /* superseded */ }
1587
+ try { fs.unlinkSync(reapPath); } catch { /* already gone */ }
1588
+ sleepMs(retryDelayMs);
1589
+ continue;
1590
+ }
1479
1591
  }
1480
1592
  continue; // lock just removed — retry immediately
1481
1593
  }
@@ -1490,31 +1602,64 @@ function withFileLock(lockPath, fn, {
1490
1602
  sleepMs(retryDelayMs);
1491
1603
  }
1492
1604
  }
1493
- if (fd === null) {
1605
+ if (!acquired) {
1494
1606
  lastErr = new Error(`Lock timeout: ${lockPath}`);
1495
1607
  continue; // retry if attempts remain
1496
1608
  }
1497
1609
 
1610
+ let result;
1611
+ let thrown = null;
1498
1612
  try {
1499
- const result = fn();
1613
+ result = fn();
1500
1614
  // P-a3f9b2c1 — Defensive: detect a thenable return and throw synchronously.
1501
- // The `finally` below releases the lock immediately after `fn()` returns;
1502
- // an async callback would let the lock be released before its body
1503
- // completes, silently breaking mutual exclusion. Clean up our own fd /
1504
- // lock first, then throw so the caller cannot ignore the failure.
1615
+ // The lock is released immediately after `fn()` returns; an async callback
1616
+ // would let the lock be released before its body completes, silently
1617
+ // breaking mutual exclusion. Release below, then throw so the caller
1618
+ // cannot ignore the failure.
1505
1619
  if (result && typeof result.then === 'function') {
1506
- try { fs.closeSync(fd); } catch { /* cleanup */ }
1507
- try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
1508
- fd = null; // suppress double-cleanup in `finally`
1620
+ result = undefined;
1509
1621
  throw new Error('withFileLock: fn must be synchronous; got Promise. Use synchronous operations only.');
1510
1622
  }
1511
- return result;
1512
- } finally {
1513
- if (fd !== null) {
1514
- try { fs.closeSync(fd); } catch { /* cleanup */ }
1515
- try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
1623
+ } catch (err) {
1624
+ thrown = err;
1625
+ }
1626
+
1627
+ // Release by IDENTITY. If the lock file is gone, or now carries a different
1628
+ // token, a reaper stole it mid-section: someone else was in the critical
1629
+ // section with us, so (a) do NOT unlink — that would destroy the successor's
1630
+ // lock and cascade the breach — and (b) report the breach instead of
1631
+ // silently returning a value that may have been produced from, or clobbered
1632
+ // by, a raced snapshot.
1633
+ let compromised = false;
1634
+ const held = tokenPersisted ? _readLockPayload(lockPath) : null;
1635
+ if (held && held.missing) {
1636
+ compromised = true;
1637
+ } else if (held && held.payload && held.payload.token && held.payload.token !== token) {
1638
+ compromised = true;
1639
+ } else {
1640
+ try {
1641
+ fs.unlinkSync(lockPath);
1642
+ } catch (unlinkErr) {
1643
+ // ENOENT means it was removed under us — same breach as `missing`.
1644
+ if (unlinkErr && unlinkErr.code === 'ENOENT') compromised = true;
1645
+ // Anything else is best-effort; the stale reaper will clear it.
1516
1646
  }
1517
1647
  }
1648
+
1649
+ if (thrown) throw thrown;
1650
+ if (compromised) {
1651
+ if (typeof onCompromised === 'function' && redosLeft > 0) {
1652
+ redosLeft--;
1653
+ if (onCompromised(result) !== 'redo') return result;
1654
+ attempt--; // a compromised section is not a failed acquisition attempt
1655
+ sleepMs(retryDelayMs);
1656
+ continue;
1657
+ }
1658
+ const err = new Error(`Lock compromised (concurrent holder detected): ${lockPath}`);
1659
+ err.code = 'ELOCKCOMPROMISED';
1660
+ throw err;
1661
+ }
1662
+ return result;
1518
1663
  }
1519
1664
  throw lastErr;
1520
1665
  }
@@ -1640,7 +1785,21 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1640
1785
  }
1641
1786
  throw error;
1642
1787
  }
1643
- }, { retries, retryBackoffMs });
1788
+ }, {
1789
+ retries,
1790
+ retryBackoffMs,
1791
+ compromiseRedos: LOCK_COMPROMISE_REDOS,
1792
+ // Our lock was stolen mid-section, so another writer shared it and one of
1793
+ // the two updates was clobbered. Redo the read-modify-write against the
1794
+ // current on-disk state only if our own write did not survive; the other
1795
+ // writer detects and redoes its own loss the same way, so the union is
1796
+ // preserved without double-applying an intact mutation.
1797
+ onCompromised: (written) => {
1798
+ if (written === null) return fs.existsSync(filePath) ? 'redo' : 'accept';
1799
+ const current = safeJsonNoRestore(filePath);
1800
+ return JSON.stringify(current) === JSON.stringify(written) ? 'accept' : 'redo';
1801
+ },
1802
+ });
1644
1803
  }
1645
1804
 
1646
1805
  function mutateControl(mutator) {
@@ -9264,6 +9423,9 @@ module.exports = {
9264
9423
  resolveDispatchPrompt,
9265
9424
  deleteDispatchPromptSidecar,
9266
9425
  withFileLock,
9426
+ _readLockPayload, // exported for testing
9427
+ _isSameLockAcquisition, // exported for testing
9428
+ LOCK_COMPROMISE_REDOS, // exported for testing
9267
9429
  mutateJsonFileLocked,
9268
9430
  mutateControl, recordEngineRespawn,
9269
9431
  mutateEngineState, // W-mp60tw0u000j3931
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2426",
3
+ "version": "0.1.2428",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"