leerness 1.36.171 → 1.36.173
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 +8 -0
- package/README.md +4 -4
- package/bin/leerness.js +72 -13
- package/lib/workspace-dir.js +82 -14
- package/package.json +5 -4
- package/scripts/e2e-core.js +30 -2
- package/scripts/workspace-dir-lock-order-probe.js +206 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.173 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
- Memory DELETE 5종(task/decision/lesson/rule/plan)의 성공 --json 경로를 단일 JSON 문서·clean stderr 계약으로 통일했습니다. onEveryChange 자동 로드맵은 JSON 출력에 사람용 로그를 섞지 않으면서 다섯 명령 각각 갱신되며, 명령별 산출물 재생성과 외부 Codex P2 재현을 회귀로 고정했습니다.
|
|
6
|
+
|
|
7
|
+
## 1.36.172 — 2026-08-27
|
|
8
|
+
|
|
9
|
+
- GitHub CI에서 재현된 동시 최초 .harness→.leerness 마이그레이션 TOCTOU를 수정했습니다. 라이브 마이그레이션이 두 번째 상태 검사와 충돌 스캔 전에 프로젝트 잠금을 획득하며, 실제 peer EEXIST와 잠금 해제 직전까지의 대기를 검증하는 결정론적 회귀를 fast/core/full 게이트에 연결했습니다.
|
|
10
|
+
|
|
3
11
|
## 1.36.171 — 2026-08-27
|
|
4
12
|
|
|
5
13
|
- 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.173 하네스를 사용합니다. 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.173는 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.173 릴리스 태그 이력** (수백 라운드) · _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.173: 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.173';
|
|
52
52
|
|
|
53
53
|
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
54
54
|
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
|
@@ -10619,7 +10619,37 @@ function _p0103JsonOk() {
|
|
|
10619
10619
|
// ④ --json 이 아예 구현되지 않아 사람용 텍스트를 내던 표면(게시본 3/30) — 계약 스윕
|
|
10620
10620
|
const sweepBad = [['consistency', 'check', '--json'], ['agent-mode', 'tick', '--json'], ['graph', '--json']]
|
|
10621
10621
|
.filter(a => !parse(R(a)));
|
|
10622
|
-
|
|
10622
|
+
// ⑤ Memory DELETE 성공 경로도 같은 계약을 지킨다. 종전에는 mutation은 끝냈지만
|
|
10623
|
+
// stdout은 비고 사람용 ✓ 문구만 stderr에 남아 JSON 소비자가 성공할 때만 깨졌다(T-0093).
|
|
10624
|
+
const taskAdded = parse(R(['task', 'add', 'json-delete-task', '--json']));
|
|
10625
|
+
R(['decision', 'add', 'json-delete-decision']);
|
|
10626
|
+
R(['lesson', 'save', 'json-delete-lesson']);
|
|
10627
|
+
const ruleAdded = parse(R(['rule', 'add', 'json-delete-rule', '--trigger', 'every-session', '--json']));
|
|
10628
|
+
R(['plan', 'add', 'json-delete-plan']);
|
|
10629
|
+
if (!taskAdded?.id || !ruleAdded?.id) return false;
|
|
10630
|
+
fs.mkdirSync(path.join(proj, '.leerness', 'cache'), { recursive: true });
|
|
10631
|
+
fs.writeFileSync(path.join(proj, '.leerness', 'cache', 'auto-roadmap.json'), JSON.stringify({ enabled: true, onEveryChange: true }));
|
|
10632
|
+
const liveEnv = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' });
|
|
10633
|
+
delete liveEnv.LEERNESS_NO_AUTO_ROADMAP;
|
|
10634
|
+
const RLive = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: proj, encoding: 'utf8', timeout: 180000, env: liveEnv });
|
|
10635
|
+
const deleteArgs = [
|
|
10636
|
+
['task', 'drop', taskAdded.id, '--json'],
|
|
10637
|
+
['decision', 'drop', 'json-delete-decision', '--json'],
|
|
10638
|
+
['lesson', 'drop', 'json-delete-lesson', '--json'],
|
|
10639
|
+
['rule', 'remove', ruleAdded.id, '--json'],
|
|
10640
|
+
['plan', 'remove', 'json-delete-plan', '--json'],
|
|
10641
|
+
];
|
|
10642
|
+
const graphPath = path.join(proj, 'leerness.html');
|
|
10643
|
+
const deleteResults = [];
|
|
10644
|
+
let roadmapOk = true;
|
|
10645
|
+
for (const args of deleteArgs) {
|
|
10646
|
+
fs.rmSync(graphPath, { force: true });
|
|
10647
|
+
deleteResults.push(RLive(args));
|
|
10648
|
+
roadmapOk = roadmapOk && fs.existsSync(graphPath);
|
|
10649
|
+
}
|
|
10650
|
+
const deleteOk = deleteResults.every(r => r.status === 0 && !(r.stderr || '').trim() && parse(r)?.ok === true)
|
|
10651
|
+
&& roadmapOk;
|
|
10652
|
+
return failuresOk && successOk && keepOk && sweepBad.length === 0 && deleteOk;
|
|
10623
10653
|
} catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
|
|
10624
10654
|
}
|
|
10625
10655
|
// 1.36.103: 값-플래그 등록 완전성 + 오타 가시성 회귀 가드.
|
|
@@ -14688,9 +14718,9 @@ function planProgress(root, opts = {}) {
|
|
|
14688
14718
|
// Memory Surface DELETE 5종 완전 완성 (task drop / decision drop / lesson drop / rule remove / plan remove)
|
|
14689
14719
|
function planRemoveCmd(root, target) {
|
|
14690
14720
|
root = absRoot(root);
|
|
14691
|
-
if (!target) return
|
|
14721
|
+
if (!target) return failJson(has('--json'), 'missing_target', 'plan remove <M-XXXX|title-substring> 필요 — 매칭되는 milestone 블록을 제거하고 .leerness/plan.archive.md에 보존');
|
|
14692
14722
|
const pp = planPath(root);
|
|
14693
|
-
if (!exists(pp)) return
|
|
14723
|
+
if (!exists(pp)) return failJson(has('--json'), 'plan_not_found', 'plan.md 없음');
|
|
14694
14724
|
let removed = 0;
|
|
14695
14725
|
let disappeared = false;
|
|
14696
14726
|
// 1.36.152 (T-0115): plan restore/add와 archive를 공유한다. 락 안에서 다시 읽어야 새 milestone을 덮지 않는다.
|
|
@@ -14730,8 +14760,13 @@ function planRemoveCmd(root, target) {
|
|
|
14730
14760
|
if (removed === 0) return;
|
|
14731
14761
|
writeUtf8(pp, kept.join('\n'));
|
|
14732
14762
|
});
|
|
14733
|
-
if (disappeared) return
|
|
14763
|
+
if (disappeared) return failJson(has('--json'), 'plan_not_found', 'plan.md 없음');
|
|
14734
14764
|
if (removed === 0) return failJson(has('--json'), 'milestone_not_found', `매칭 milestone 없음: "${target}"`); // codex P2: --json 에러 구조화
|
|
14765
|
+
if (has('--json')) {
|
|
14766
|
+
_autoRoadmap(absRoot(root), 'data-change', { quiet: true });
|
|
14767
|
+
log(JSON.stringify({ ok: true, surface: 'plan', action: 'remove', target, removedCount: removed, archive: '.leerness/plan.archive.md' }, null, 2));
|
|
14768
|
+
return;
|
|
14769
|
+
}
|
|
14735
14770
|
ok(`milestone removed: ${removed}건 (보존: .leerness/plan.archive.md)`);
|
|
14736
14771
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
14737
14772
|
}
|
|
@@ -15065,12 +15100,19 @@ function taskUpdate(root, id) {
|
|
|
15065
15100
|
}
|
|
15066
15101
|
function taskDrop(root, id) {
|
|
15067
15102
|
if (!_requireInit(root, 'task drop')) return; // 1.9.396 (6번째 외부평가/codex P1-B): init 가드
|
|
15068
|
-
if (!id) return
|
|
15103
|
+
if (!id) return failJson(has('--json'), 'missing_id', 'id required');
|
|
15069
15104
|
const rows = readProgressRows(root);
|
|
15070
15105
|
// 1.9.396 (6번째 외부평가/codex P1-B): 없는 task drop 시 가짜 row(request undefined) 생성 = 데이터 손상 → task update 와 동일하게 존재 확인 후 fail(no-op).
|
|
15071
15106
|
if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
|
|
15072
15107
|
const _tdEn = _uiLang(root) === 'en'; // 1.36.63 (검수 #2)
|
|
15073
|
-
|
|
15108
|
+
const evidence = arg('--reason', _tdEn ? 'dropped by user request' : '사용자 요청으로 제외');
|
|
15109
|
+
const nextAction = _tdEn ? 'none' : '없음';
|
|
15110
|
+
upsertProgress(root, { id, status: 'dropped', evidence, nextAction });
|
|
15111
|
+
if (has('--json')) {
|
|
15112
|
+
_autoRoadmap(absRoot(root), 'data-change', { quiet: true });
|
|
15113
|
+
log(JSON.stringify({ ok: true, surface: 'tasks', action: 'drop', id, status: 'dropped', evidence, nextAction }, null, 2));
|
|
15114
|
+
return;
|
|
15115
|
+
}
|
|
15074
15116
|
ok(`task dropped: ${id}`);
|
|
15075
15117
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
15076
15118
|
}
|
|
@@ -15385,7 +15427,7 @@ function lessonDropCmd(root, target) {
|
|
|
15385
15427
|
// 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
|
|
15386
15428
|
// 그 경로와 `.leerness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
|
|
15387
15429
|
if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
|
|
15388
|
-
if (!target) return
|
|
15430
|
+
if (!target) return failJson(has('--json'), 'missing_target', 'lesson drop <date|text-substring> 필요. 예: leerness lesson drop "2026-05-20" 또는 leerness lesson drop "JWT"');
|
|
15389
15431
|
// archive 보존 — lessons.archive.md 에 projection MD 블록 형태로 추가
|
|
15390
15432
|
const archivePath = path.join(root, '.leerness/lessons.archive.md');
|
|
15391
15433
|
// 1.36.140 (재검수 P1, 재현): 종전엔 락 **밖**에서 매칭한 뒤 락 안에서 `${date}|${text}` 복합 키로
|
|
@@ -15417,6 +15459,11 @@ function lessonDropCmd(root, target) {
|
|
|
15417
15459
|
});
|
|
15418
15460
|
if (loadErr === 'no_lessons') return failJson(has('--json'), 'no_lessons', 'lessons 없음'); // codex P2: --json 에러 구조화
|
|
15419
15461
|
if (loadErr === 'lesson_not_found' || !removed.length) return failJson(has('--json'), 'lesson_not_found', `매칭 lesson 없음: "${target}"`);
|
|
15462
|
+
if (has('--json')) {
|
|
15463
|
+
_autoRoadmap(absRoot(root), 'data-change', { quiet: true });
|
|
15464
|
+
log(JSON.stringify({ ok: true, surface: 'lessons', action: 'drop', target, removedCount: removed.length, archive: '.leerness/lessons.archive.md' }, null, 2));
|
|
15465
|
+
return;
|
|
15466
|
+
}
|
|
15420
15467
|
ok(`lesson dropped: ${removed.length}건 (보존: .leerness/lessons.archive.md)`);
|
|
15421
15468
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
15422
15469
|
}
|
|
@@ -15494,7 +15541,7 @@ function decisionDropCmd(root, target) {
|
|
|
15494
15541
|
// 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
|
|
15495
15542
|
// 그 경로와 `.leerness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
|
|
15496
15543
|
if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
|
|
15497
|
-
if (!target) return
|
|
15544
|
+
if (!target) return failJson(has('--json'), 'missing_target', 'decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
|
|
15498
15545
|
// 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
|
|
15499
15546
|
const archivePath = path.join(root, '.leerness/decisions.archive.md');
|
|
15500
15547
|
// 1.36.138 (동시성 감사 실측): `decision add` 는 락 안인데 **drop 은 락 밖**이었다 —
|
|
@@ -15527,6 +15574,11 @@ function decisionDropCmd(root, target) {
|
|
|
15527
15574
|
});
|
|
15528
15575
|
if (loadErr === 'no_decisions') return failJson(has('--json'), 'no_decisions', 'decisions 없음'); // codex P2: --json 에러 구조화
|
|
15529
15576
|
if (loadErr === 'decision_not_found' || !removed.length) return failJson(has('--json'), 'decision_not_found', `매칭 decision 없음: "${target}"`);
|
|
15577
|
+
if (has('--json')) {
|
|
15578
|
+
_autoRoadmap(absRoot(root), 'data-change', { quiet: true });
|
|
15579
|
+
log(JSON.stringify({ ok: true, surface: 'decisions', action: 'drop', target, removedCount: removed.length, archive: '.leerness/decisions.archive.md' }, null, 2));
|
|
15580
|
+
return;
|
|
15581
|
+
}
|
|
15530
15582
|
ok(`decision dropped: ${removed.length}건 (보존: .leerness/decisions.archive.md)`);
|
|
15531
15583
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
15532
15584
|
}
|
|
@@ -22555,7 +22607,8 @@ function _autoRoadmapConfig(root) {
|
|
|
22555
22607
|
function _saveAutoRoadmapConfig(root, cfg) {
|
|
22556
22608
|
writeUtf8(_autoRoadmapConfigPath(root), JSON.stringify(cfg, null, 2) + '\n');
|
|
22557
22609
|
}
|
|
22558
|
-
function _autoRoadmap(root, trigger) {
|
|
22610
|
+
function _autoRoadmap(root, trigger, opts = {}) {
|
|
22611
|
+
const quiet = !!(opts && opts.quiet);
|
|
22559
22612
|
try {
|
|
22560
22613
|
if (process.env.LEERNESS_NO_AUTO_ROADMAP === '1') return false;
|
|
22561
22614
|
if (!exists(path.join(root, '.leerness'))) return false;
|
|
@@ -22569,10 +22622,10 @@ function _autoRoadmap(root, trigger) {
|
|
|
22569
22622
|
const outFile = path.resolve(cfg.outFile || path.join(root, 'leerness.html'));
|
|
22570
22623
|
const s = _graph.graphHtmlCmd(root, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }, outFile);
|
|
22571
22624
|
const _en = _uiLang(root) === 'en';
|
|
22572
|
-
log(_en ? `✓ ontology graph auto-updated (${trigger}) — ${rel(root, outFile)} (graph+roadmap+toggles)` : `✓ 온톨로지 그래프 자동 갱신 (${trigger}) — ${rel(root, outFile)} (그래프+로드맵+토글 탭)`);
|
|
22625
|
+
if (!quiet) log(_en ? `✓ ontology graph auto-updated (${trigger}) — ${rel(root, outFile)} (graph+roadmap+toggles)` : `✓ 온톨로지 그래프 자동 갱신 (${trigger}) — ${rel(root, outFile)} (그래프+로드맵+토글 탭)`);
|
|
22573
22626
|
return true;
|
|
22574
22627
|
} catch (e) {
|
|
22575
|
-
warn((_uiLang(root) === 'en' ? 'roadmap auto-update failed: ' : 'roadmap 자동 갱신 실패: ') + (e && e.message ? e.message : e));
|
|
22628
|
+
if (!quiet) warn((_uiLang(root) === 'en' ? 'roadmap auto-update failed: ' : 'roadmap 자동 갱신 실패: ') + (e && e.message ? e.message : e));
|
|
22576
22629
|
return false;
|
|
22577
22630
|
}
|
|
22578
22631
|
}
|
|
@@ -22850,7 +22903,7 @@ function _updateRules(root, mutate) {
|
|
|
22850
22903
|
|
|
22851
22904
|
function ruleRemove(root, id) {
|
|
22852
22905
|
root = absRoot(root);
|
|
22853
|
-
if (!id) return
|
|
22906
|
+
if (!id) return failJson(has('--json'), 'missing_id', 'id required');
|
|
22854
22907
|
let removed = null;
|
|
22855
22908
|
const res = _updateRules(root, (rules) => {
|
|
22856
22909
|
const i = rules.findIndex(r => r.id === id);
|
|
@@ -22863,7 +22916,13 @@ function ruleRemove(root, id) {
|
|
|
22863
22916
|
return removed;
|
|
22864
22917
|
});
|
|
22865
22918
|
if (res.error) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
|
|
22919
|
+
if (has('--json')) {
|
|
22920
|
+
_autoRoadmap(root, 'data-change', { quiet: true });
|
|
22921
|
+
log(JSON.stringify({ ok: true, surface: 'rules', action: 'remove', id, removedCount: 1, archive: '.leerness/rules.archive.md' }, null, 2));
|
|
22922
|
+
return;
|
|
22923
|
+
}
|
|
22866
22924
|
ok(`rule removed: ${id} (보존: .leerness/rules.archive.md)`);
|
|
22925
|
+
_autoRoadmap(root, 'data-change');
|
|
22867
22926
|
}
|
|
22868
22927
|
|
|
22869
22928
|
function rulePause(root, id) {
|
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.173",
|
|
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",
|
package/scripts/e2e-core.js
CHANGED
|
@@ -207,12 +207,40 @@ console.log('# leerness core (test:core) — flagship behavioral guarantees');
|
|
|
207
207
|
// (10) --json 무결성: 변형/조회 명령이 성공 경로에서도 유효 JSON (1.36.0, 클린룸 리뷰 B)
|
|
208
208
|
{
|
|
209
209
|
const d = fresh();
|
|
210
|
-
const
|
|
211
|
-
|
|
210
|
+
const jsonResult = (r) => { try { return JSON.parse((r.stdout || '').trim()); } catch { return null; } };
|
|
211
|
+
const okJson = (r) => { const payload = jsonResult(r); return r.status === 0 && !(r.stderr || '').trim() && payload !== null && payload.ok !== false; };
|
|
212
|
+
const addedTask = jsonResult(run(d, ['task', 'add', 'sample', '--json']));
|
|
212
213
|
assert('json: task update --status done --json → valid JSON (was plain text)', okJson(run(d, ['task', 'update', 'T-0002', '--status', 'done', '--json'])));
|
|
213
214
|
assert('json: plan add --json → valid JSON', okJson(run(d, ['plan', 'add', 'a milestone', '--json'])));
|
|
214
215
|
assert('json: rule verify --json → valid JSON', okJson(run(d, ['rule', 'verify', '--json'])));
|
|
215
216
|
assert('json: reuse find --json → valid JSON', okJson(run(d, ['reuse', 'find', 'button', '--json'])));
|
|
217
|
+
|
|
218
|
+
// T-0093: Memory DELETE 다섯 표면은 mutation은 성공하면서 JSON을 전혀 내지 않고
|
|
219
|
+
// 사람용 ✓ 문구만 stderr에 썼다. 성공 payload와 stderr-0을 함께 고정한다.
|
|
220
|
+
run(d, ['decision', 'add', 'json delete decision']);
|
|
221
|
+
run(d, ['lesson', 'save', 'json delete lesson']);
|
|
222
|
+
const addedRule = jsonResult(run(d, ['rule', 'add', 'json delete rule', '--trigger', 'every-session', '--json']));
|
|
223
|
+
run(d, ['plan', 'add', 'json delete milestone']);
|
|
224
|
+
run(d, ['roadmap', 'auto', 'on', '--on-every-change']);
|
|
225
|
+
const deleteCases = [
|
|
226
|
+
['task drop', ['task', 'drop', addedTask.id, '--json'], 'tasks'],
|
|
227
|
+
['decision drop', ['decision', 'drop', 'json delete decision', '--json'], 'decisions'],
|
|
228
|
+
['lesson drop', ['lesson', 'drop', 'json delete lesson', '--json'], 'lessons'],
|
|
229
|
+
['rule remove', ['rule', 'remove', addedRule.id, '--json'], 'rules'],
|
|
230
|
+
['plan remove', ['plan', 'remove', 'json delete milestone', '--json'], 'plan'],
|
|
231
|
+
];
|
|
232
|
+
const graphPath = path.join(d, 'leerness.html');
|
|
233
|
+
for (const [label, args, surface] of deleteCases) {
|
|
234
|
+
fs.rmSync(graphPath, { force: true });
|
|
235
|
+
const result = run(d, args);
|
|
236
|
+
const payload = jsonResult(result);
|
|
237
|
+
assert(`json: ${label} --json → one success document + clean stderr + silent roadmap refresh`,
|
|
238
|
+
result.status === 0 && !(result.stderr || '').trim() && payload?.ok === true
|
|
239
|
+
&& payload.surface === surface
|
|
240
|
+
&& (surface === 'tasks' ? payload.status === 'dropped' : payload.removedCount === 1)
|
|
241
|
+
&& fs.existsSync(graphPath),
|
|
242
|
+
`status=${result.status} stdout=${result.stdout} stderr=${result.stderr}`);
|
|
243
|
+
}
|
|
216
244
|
fs.rmSync(d, { recursive: true, force: true });
|
|
217
245
|
}
|
|
218
246
|
|
|
@@ -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
|
+
});
|