@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.
- package/docs/design-state-storage.md +3 -1
- package/engine/shared.js +199 -37
- package/package.json +1 -1
|
@@ -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
|
-
}, {
|
|
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
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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.
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
const tolerable =
|
|
1477
|
-
(process.platform === 'win32' &&
|
|
1478
|
-
if (!tolerable) throw
|
|
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 (
|
|
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
|
-
|
|
1613
|
+
result = fn();
|
|
1500
1614
|
// P-a3f9b2c1 — Defensive: detect a thenable return and throw synchronously.
|
|
1501
|
-
// The
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
}, {
|
|
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.
|
|
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"
|