leerness 1.36.171 → 1.36.172
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/CHANGELOG.md +4 -0
- package/README.md +4 -4
- package/bin/leerness.js +1 -1
- package/lib/workspace-dir.js +82 -14
- package/package.json +5 -4
- package/scripts/workspace-dir-lock-order-probe.js +206 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.172 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
- GitHub CI에서 재현된 동시 최초 .harness→.leerness 마이그레이션 TOCTOU를 수정했습니다. 라이브 마이그레이션이 두 번째 상태 검사와 충돌 스캔 전에 프로젝트 잠금을 획득하며, 실제 peer EEXIST와 잠금 해제 직전까지의 대기를 검증하는 결정론적 회귀를 fast/core/full 게이트에 연결했습니다.
|
|
6
|
+
|
|
3
7
|
## 1.36.171 — 2026-08-27
|
|
4
8
|
|
|
5
9
|
- MCP handoff/session-close가 호출별 sessionKey로 presence를 등록·종료하고, Windows 환경변수 casing·일회성 marker·legacy case variant·손상 closedAt를 안전하게 처리하도록 개선했습니다. 전용 MCP presence 회귀 22/22와 전체 E2E 467/467를 추가·통과했습니다.
|
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ MIT
|
|
|
138
138
|
<!-- leerness:project-readme:start -->
|
|
139
139
|
## Leerness Project Harness
|
|
140
140
|
|
|
141
|
-
이 프로젝트는 Leerness v1.36.
|
|
141
|
+
이 프로젝트는 Leerness v1.36.172 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
142
142
|
|
|
143
143
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
144
144
|
|
|
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
|
|
|
192
192
|
|
|
193
193
|
### MCP server (외부 AI 통합)
|
|
194
194
|
|
|
195
|
-
Leerness v1.36.
|
|
195
|
+
Leerness v1.36.172는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
196
196
|
|
|
197
197
|
```jsonc
|
|
198
198
|
// 카테고리별
|
|
@@ -213,7 +213,7 @@ Leerness v1.36.171는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
213
213
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
214
214
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
215
215
|
|
|
216
|
-
현재 누적: **v1.9.x → 1.36.
|
|
216
|
+
현재 누적: **v1.9.x → 1.36.172 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
217
217
|
|
|
218
218
|
### 성능 가이드
|
|
219
219
|
|
|
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
|
|
|
251
251
|
- `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
252
252
|
- `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
253
253
|
|
|
254
|
-
Last synced by Leerness v1.36.
|
|
254
|
+
Last synced by Leerness v1.36.172: 2026-08-27
|
|
255
255
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -48,7 +48,7 @@ const {
|
|
|
48
48
|
migrateLegacyWorkspace,
|
|
49
49
|
} = require('../lib/workspace-dir');
|
|
50
50
|
|
|
51
|
-
const VERSION = '1.36.
|
|
51
|
+
const VERSION = '1.36.172';
|
|
52
52
|
|
|
53
53
|
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
54
54
|
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
package/lib/workspace-dir.js
CHANGED
|
@@ -376,18 +376,18 @@ function _sameDestinationSnapshot(a, b) {
|
|
|
376
376
|
return !!a && !!b && a.kind === b.kind && a.hash === b.hash;
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
379
|
+
const _MIGRATION_LOCK_OWNED = Symbol('leerness.workspace-migration-lock-owned');
|
|
380
|
+
|
|
381
|
+
function _migrationLockWaitMs(opts = {}) {
|
|
382
|
+
const parsed = Number(opts.lockWaitMs);
|
|
383
|
+
return Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function _newMigrationReport(state, opts = {}) {
|
|
384
387
|
const at = opts.at || new Date().toISOString();
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
: 10000;
|
|
389
|
-
const lockFile = path.join(state.root, '.leerness-workspace-migration.lock');
|
|
390
|
-
const report = {
|
|
388
|
+
const version = String(opts.version || 'unknown');
|
|
389
|
+
const dryRun = opts.dryRun === true;
|
|
390
|
+
return {
|
|
391
391
|
at,
|
|
392
392
|
version,
|
|
393
393
|
src: state.legacy.abs,
|
|
@@ -410,6 +410,74 @@ function migrateLegacyWorkspace(root, opts = {}) {
|
|
|
410
410
|
waitedForLock: false,
|
|
411
411
|
lockWaitedMs: 0,
|
|
412
412
|
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Live first migrations serialize before the second workspace inspection or
|
|
416
|
+
// any destination conflict scan. Acquiring later leaves a TOCTOU window where
|
|
417
|
+
// a peer's in-progress canonical copy is mistaken for user-authored conflict.
|
|
418
|
+
// Dry-runs and already-canonical roots stay read-only and skip lock creation.
|
|
419
|
+
function migrateLegacyWorkspace(root, opts = {}) {
|
|
420
|
+
const initial = inspectWorkspace(root);
|
|
421
|
+
if (opts.dryRun === true || opts[_MIGRATION_LOCK_OWNED] === true || !initial.legacy.exists) {
|
|
422
|
+
return _migrateLegacyWorkspace(initial.root, opts);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const lockWaitMs = _migrationLockWaitMs(opts);
|
|
426
|
+
const lockFile = path.join(initial.root, '.leerness-workspace-migration.lock');
|
|
427
|
+
const startedAt = Date.now();
|
|
428
|
+
const deadline = startedAt + lockWaitMs;
|
|
429
|
+
let lockFd = null;
|
|
430
|
+
let waited = false;
|
|
431
|
+
|
|
432
|
+
while (lockFd === null) {
|
|
433
|
+
try { lockFd = fs.openSync(lockFile, 'wx'); }
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (!error || (error.code !== 'EEXIST' && !_isTransientLockFsError(error))) {
|
|
436
|
+
const report = _newMigrationReport(initial, opts);
|
|
437
|
+
report.blocked = true;
|
|
438
|
+
report.blockedReason = 'workspace-dir-migration-failed';
|
|
439
|
+
report.errors.push(error && error.message ? error.message : String(error));
|
|
440
|
+
report.waitedForLock = waited;
|
|
441
|
+
report.lockWaitedMs = waited ? Math.max(0, Date.now() - startedAt) : 0;
|
|
442
|
+
return report;
|
|
443
|
+
}
|
|
444
|
+
waited = true;
|
|
445
|
+
if (Date.now() >= deadline) {
|
|
446
|
+
const report = _newMigrationReport(initial, opts);
|
|
447
|
+
report.blocked = true;
|
|
448
|
+
report.blockedReason = 'workspace-dir-migration-locked';
|
|
449
|
+
report.errors.push(`another workspace migration still owns: ${lockFile}`);
|
|
450
|
+
report.waitedForLock = true;
|
|
451
|
+
report.lockWaitedMs = Math.max(0, Date.now() - startedAt);
|
|
452
|
+
return report;
|
|
453
|
+
}
|
|
454
|
+
_sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const report = _migrateLegacyWorkspace(initial.root, { ...opts, [_MIGRATION_LOCK_OWNED]: true });
|
|
460
|
+
if (waited) {
|
|
461
|
+
report.waitedForLock = true;
|
|
462
|
+
report.lockWaitedMs = Math.max(report.lockWaitedMs || 0, Date.now() - startedAt);
|
|
463
|
+
}
|
|
464
|
+
return report;
|
|
465
|
+
} finally {
|
|
466
|
+
try { fs.closeSync(lockFd); } catch {}
|
|
467
|
+
_removeOwnedLockFile(lockFile);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function _migrateLegacyWorkspace(root, opts = {}) {
|
|
472
|
+
const state = inspectWorkspace(root);
|
|
473
|
+
const dryRun = opts.dryRun === true;
|
|
474
|
+
const migrationLockOwned = opts[_MIGRATION_LOCK_OWNED] === true;
|
|
475
|
+
const force = opts.force === true;
|
|
476
|
+
const version = String(opts.version || 'unknown');
|
|
477
|
+
const at = opts.at || new Date().toISOString();
|
|
478
|
+
const lockWaitMs = _migrationLockWaitMs(opts);
|
|
479
|
+
const lockFile = path.join(state.root, '.leerness-workspace-migration.lock');
|
|
480
|
+
const report = _newMigrationReport(state, opts);
|
|
413
481
|
|
|
414
482
|
const block = (reason, message, extra = {}) => {
|
|
415
483
|
report.blocked = true;
|
|
@@ -423,7 +491,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
|
|
|
423
491
|
// look foreign or dual-live while copying. Observe that transaction before
|
|
424
492
|
// applying any conflict verdict; otherwise simultaneous first handoffs race
|
|
425
493
|
// against an intermediate state and some fail before reaching the lock.
|
|
426
|
-
if (!dryRun && fs.existsSync(lockFile)) {
|
|
494
|
+
if (!dryRun && !migrationLockOwned && fs.existsSync(lockFile)) {
|
|
427
495
|
const peerStartedAt = Date.now();
|
|
428
496
|
const peerDeadline = peerStartedAt + lockWaitMs;
|
|
429
497
|
report.waitedForLock = true;
|
|
@@ -559,7 +627,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
|
|
|
559
627
|
const stamp = at.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 96) || String(Date.now());
|
|
560
628
|
const migrationRoot = path.join(state.canonical.abs, 'archive', `workspace-dir-migration-${stamp}`);
|
|
561
629
|
const canonicalBackup = path.join(migrationRoot, 'canonical-before-merge');
|
|
562
|
-
let lockFd = null;
|
|
630
|
+
let lockFd = migrationLockOwned ? undefined : null;
|
|
563
631
|
const created = [];
|
|
564
632
|
const overwritten = [];
|
|
565
633
|
const canonicalExisted = state.canonical.exists;
|
|
@@ -750,7 +818,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
|
|
|
750
818
|
}
|
|
751
819
|
return report;
|
|
752
820
|
} finally {
|
|
753
|
-
if (lockFd !== null) {
|
|
821
|
+
if (!migrationLockOwned && lockFd !== null) {
|
|
754
822
|
try { fs.closeSync(lockFd); } catch {}
|
|
755
823
|
_removeOwnedLockFile(lockFile);
|
|
756
824
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.172",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
|
@@ -48,13 +48,14 @@
|
|
|
48
48
|
"LICENSE"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
|
|
52
|
-
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
|
|
51
|
+
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
|
|
52
|
+
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
|
|
53
53
|
"test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
|
|
54
54
|
"test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
|
|
55
55
|
"test:installed": "node ./scripts/installed-cleanroom-probe.js",
|
|
56
56
|
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
57
|
-
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/smoke.js",
|
|
57
|
+
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/smoke.js",
|
|
58
|
+
"test:workspace-lock-order": "node ./scripts/workspace-dir-lock-order-probe.js",
|
|
58
59
|
"test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
|
|
59
60
|
"test:smoke": "node ./scripts/smoke.js",
|
|
60
61
|
"lint": "node ./scripts/lint.js",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// A live legacy migration must own its project lock before the second workspace
|
|
5
|
+
// inspection. Otherwise a peer can populate `.leerness/` after validation and
|
|
6
|
+
// make the first caller misclassify the peer's intermediate copy as a user
|
|
7
|
+
// conflict. This probe forces that interleaving without relying on scheduler luck.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const cp = require('child_process');
|
|
13
|
+
const workspace = require('../lib/workspace-dir');
|
|
14
|
+
|
|
15
|
+
const IS_PEER = process.argv[2] === '--peer';
|
|
16
|
+
const root = IS_PEER
|
|
17
|
+
? path.resolve(process.argv[3] || '.')
|
|
18
|
+
: fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lock-order-'));
|
|
19
|
+
const legacy = path.join(root, workspace.LEGACY_WORKSPACE_DIR);
|
|
20
|
+
const canonical = path.join(root, workspace.CANONICAL_WORKSPACE_DIR);
|
|
21
|
+
const lockFile = path.join(root, '.leerness-workspace-migration.lock');
|
|
22
|
+
const peerReadyFile = path.join(root, '.leerness-lock-order-peer-ready');
|
|
23
|
+
const peerContendedFile = path.join(root, '.leerness-lock-order-peer-contended');
|
|
24
|
+
const peerDoneFile = path.join(root, '.leerness-lock-order-peer-done');
|
|
25
|
+
|
|
26
|
+
function write(file, body) {
|
|
27
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
+
fs.writeFileSync(file, body, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sleep(ms) {
|
|
32
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function waitForPeerHandshake(timeoutMs) {
|
|
36
|
+
const deadline = Date.now() + timeoutMs;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
const ready = fs.existsSync(peerReadyFile);
|
|
39
|
+
const contended = fs.existsSync(peerContendedFile);
|
|
40
|
+
const done = fs.existsSync(peerDoneFile);
|
|
41
|
+
if (ready && (contended || done)) return { ready, contended, done };
|
|
42
|
+
sleep(2);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
ready: fs.existsSync(peerReadyFile),
|
|
46
|
+
contended: fs.existsSync(peerContendedFile),
|
|
47
|
+
done: fs.existsSync(peerDoneFile),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runPeer() {
|
|
52
|
+
const originalOpen = fs.openSync;
|
|
53
|
+
fs.openSync = function patchedOpen(target, flags, ...rest) {
|
|
54
|
+
try { return originalOpen.call(fs, target, flags, ...rest); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (path.resolve(String(target)) === lockFile && flags === 'wx' && error && error.code === 'EEXIST') {
|
|
57
|
+
write(peerContendedFile, `${process.pid}\n`);
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
write(peerReadyFile, `${process.pid}\n`);
|
|
64
|
+
let report;
|
|
65
|
+
try {
|
|
66
|
+
report = workspace.migrateLegacyWorkspace(root, {
|
|
67
|
+
version: 'lock-order-peer',
|
|
68
|
+
lockWaitMs: 20000,
|
|
69
|
+
at: '2026-08-27T00:00:01.000Z',
|
|
70
|
+
});
|
|
71
|
+
} finally {
|
|
72
|
+
fs.openSync = originalOpen;
|
|
73
|
+
}
|
|
74
|
+
write(peerDoneFile, `${JSON.stringify(report)}\n`);
|
|
75
|
+
if (!report || report.blocked || (!report.migrated && !report.alreadyCanonical)) process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function main() {
|
|
79
|
+
if (IS_PEER) {
|
|
80
|
+
runPeer();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
write(path.join(legacy, 'HARNESS_VERSION'), '1.36.161\n');
|
|
85
|
+
write(path.join(legacy, 'progress-tracker.md'), `# Read ${workspace.LEGACY_WORKSPACE_DIR}/progress-tracker.md\n`);
|
|
86
|
+
for (let i = 0; i < 1200; i++) {
|
|
87
|
+
write(path.join(legacy, 'bulk', `f-${String(i).padStart(4, '0')}.md`),
|
|
88
|
+
`Read ${workspace.LEGACY_WORKSPACE_DIR}/bulk/${i}\n${'x'.repeat(2048)}\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const originalLstat = fs.lstatSync;
|
|
92
|
+
const originalUnlink = fs.unlinkSync;
|
|
93
|
+
let canonicalInspections = 0;
|
|
94
|
+
let peer = null;
|
|
95
|
+
let peerDone = null;
|
|
96
|
+
let earlyLockSeen = false;
|
|
97
|
+
let peerReadySeen = false;
|
|
98
|
+
let peerContentionSeen = false;
|
|
99
|
+
let peerCompletedBeforeRelease = false;
|
|
100
|
+
let lockReleaseObserved = false;
|
|
101
|
+
let peerBlockedUntilRelease = false;
|
|
102
|
+
|
|
103
|
+
fs.lstatSync = function patchedLstat(target, ...rest) {
|
|
104
|
+
const isCanonicalRoot = path.resolve(String(target)) === canonical;
|
|
105
|
+
let result;
|
|
106
|
+
let thrown;
|
|
107
|
+
try { result = originalLstat.call(fs, target, ...rest); }
|
|
108
|
+
catch (error) { thrown = error; }
|
|
109
|
+
|
|
110
|
+
if (isCanonicalRoot && ++canonicalInspections === 2) {
|
|
111
|
+
earlyLockSeen = fs.existsSync(lockFile);
|
|
112
|
+
peer = cp.spawn(process.execPath, [__filename, '--peer', root], {
|
|
113
|
+
cwd: root,
|
|
114
|
+
env: {
|
|
115
|
+
...process.env,
|
|
116
|
+
LEERNESS_INTERNAL: '1',
|
|
117
|
+
LEERNESS_NO_BANNER: '1',
|
|
118
|
+
LEERNESS_NO_PROMPT: '1',
|
|
119
|
+
LEERNESS_NO_AUTOCHCP: '1',
|
|
120
|
+
},
|
|
121
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
122
|
+
});
|
|
123
|
+
peerDone = new Promise((resolve) => peer.on('close', (status) => resolve(status)));
|
|
124
|
+
|
|
125
|
+
// The child records an actual failed `open(..., "wx")`, not merely that
|
|
126
|
+
// it was spawned. This proves it reached lock acquisition while the
|
|
127
|
+
// victim is paused inside its second canonical workspace inspection.
|
|
128
|
+
const handshake = waitForPeerHandshake(20000);
|
|
129
|
+
peerReadySeen = handshake.ready;
|
|
130
|
+
peerContentionSeen = handshake.contended;
|
|
131
|
+
peerCompletedBeforeRelease = handshake.done;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (thrown) throw thrown;
|
|
135
|
+
return result;
|
|
136
|
+
};
|
|
137
|
+
fs.unlinkSync = function patchedUnlink(target, ...rest) {
|
|
138
|
+
if (path.resolve(String(target)) === lockFile) {
|
|
139
|
+
lockReleaseObserved = true;
|
|
140
|
+
peerBlockedUntilRelease = peerReadySeen
|
|
141
|
+
&& peerContentionSeen
|
|
142
|
+
&& !fs.existsSync(peerDoneFile)
|
|
143
|
+
&& peer !== null
|
|
144
|
+
&& peer.exitCode === null;
|
|
145
|
+
}
|
|
146
|
+
return originalUnlink.call(fs, target, ...rest);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let victim;
|
|
150
|
+
try {
|
|
151
|
+
victim = workspace.migrateLegacyWorkspace(root, {
|
|
152
|
+
version: 'lock-order-probe',
|
|
153
|
+
lockWaitMs: 20000,
|
|
154
|
+
at: '2026-08-27T00:00:00.000Z',
|
|
155
|
+
});
|
|
156
|
+
} finally {
|
|
157
|
+
fs.lstatSync = originalLstat;
|
|
158
|
+
fs.unlinkSync = originalUnlink;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const peerStatus = peerDone ? await peerDone : null;
|
|
162
|
+
const ok = earlyLockSeen
|
|
163
|
+
&& peerReadySeen
|
|
164
|
+
&& peerContentionSeen
|
|
165
|
+
&& !peerCompletedBeforeRelease
|
|
166
|
+
&& lockReleaseObserved
|
|
167
|
+
&& peerBlockedUntilRelease
|
|
168
|
+
&& peerStatus === 0
|
|
169
|
+
&& victim && victim.migrated === true && victim.blocked === false
|
|
170
|
+
&& fs.existsSync(path.join(canonical, 'progress-tracker.md'))
|
|
171
|
+
&& fs.existsSync(path.join(root, workspace.LEGACY_BACKUP_WORKSPACE_DIR, 'progress-tracker.md'))
|
|
172
|
+
&& !fs.existsSync(legacy);
|
|
173
|
+
|
|
174
|
+
if (!ok) {
|
|
175
|
+
process.stderr.write(`WORKSPACE_LOCK_ORDER_RACE ${JSON.stringify({
|
|
176
|
+
earlyLockSeen,
|
|
177
|
+
peerReadySeen,
|
|
178
|
+
peerContentionSeen,
|
|
179
|
+
peerCompletedBeforeRelease,
|
|
180
|
+
lockReleaseObserved,
|
|
181
|
+
peerBlockedUntilRelease,
|
|
182
|
+
peerStatus,
|
|
183
|
+
victim: victim && {
|
|
184
|
+
migrated: victim.migrated,
|
|
185
|
+
alreadyCanonical: victim.alreadyCanonical,
|
|
186
|
+
blocked: victim.blocked,
|
|
187
|
+
blockedReason: victim.blockedReason,
|
|
188
|
+
errors: victim.errors,
|
|
189
|
+
},
|
|
190
|
+
})}\n`);
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
} else {
|
|
193
|
+
process.stdout.write('WORKSPACE_LOCK_ORDER_OK\n');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
main()
|
|
198
|
+
.catch((error) => {
|
|
199
|
+
process.stderr.write(`WORKSPACE_LOCK_ORDER_CRASH ${error && error.stack ? error.stack : error}\n`);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
})
|
|
202
|
+
.finally(() => {
|
|
203
|
+
if (!IS_PEER) {
|
|
204
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
205
|
+
}
|
|
206
|
+
});
|