leerness 1.36.170 → 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 +8 -0
- package/README.md +4 -4
- package/bin/leerness.js +208 -31
- package/lib/mcp-tools.js +2 -2
- package/lib/session-presence.js +100 -27
- package/lib/workspace-dir.js +82 -14
- package/package.json +6 -4
- package/scripts/e2e.js +2 -0
- package/scripts/mcp-presence-probe.js +394 -0
- package/scripts/workspace-dir-lock-order-probe.js +206 -0
package/lib/session-presence.js
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
//
|
|
13
13
|
// 이 모듈은 순수 함수만 담는다(스폰·IO 없음) — 가드가 자기 소스를 읽지 않고 행위로 검증할 수 있게.
|
|
14
14
|
|
|
15
|
-
const SESSIONS_KEEP = 20; //
|
|
15
|
+
const SESSIONS_KEEP = 20; // 보존할 최근 closed tombstone 수 — 아래 주석 참조
|
|
16
16
|
const SESSIONS_CLOSED_TTL_MS = 7 * 24 * 3600 * 1000; // 임의의 수 — 아래 주석 참조
|
|
17
|
-
// ⚠ 두 값은 **임의**다. 측정으로 유도한 것은
|
|
18
|
-
//
|
|
17
|
+
// ⚠ 두 값은 **임의**다. 측정으로 유도한 것은 closed tombstone에 상한이 있어야 한다는 사실뿐이고,
|
|
18
|
+
// 그 근거는 실측이다 — `.leerness/runs/` 는 프루닝이 없어 909개 파일까지 자랐다. 열린 peer 기록은
|
|
19
|
+
// 충돌 회피의 유일한 흔적일 수 있어 이 상한으로 삭제하지 않는다. 값 자체는 근거가 없으므로 그렇게 적는다.
|
|
19
20
|
|
|
20
21
|
const KEY_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
21
22
|
const FILE_RE = /^[A-Za-z0-9_-]{8,64}\.json$/;
|
|
@@ -25,11 +26,65 @@ const RESERVED_KEYS = new Set(['unaddressed']);
|
|
|
25
26
|
// 자체 점검이 쓰는 격리 marker는 실제 세션 주소가 될 수 없어야 한다. 그렇지 않으면 enforce install이
|
|
26
27
|
// 같은 주소의 실제 handoff를 저장/복원 사이에 되돌려 버릴 수 있다.
|
|
27
28
|
const RESERVED_PREFIXES = ['leerness-internal-'];
|
|
29
|
+
// Windows의 process.env는 키 대소문자를 구분하지 않지만, `{ ...process.env }`로 복사한
|
|
30
|
+
// 평범한 객체는 구분한다. presence 판정 직전에 아래 키만 OS 의미에 맞춰 canonical casing으로
|
|
31
|
+
// 올린다. POSIX에서는 원래의 case-sensitive 계약을 유지하고, 순수 회귀 테스트는 옵션으로
|
|
32
|
+
// Windows 의미를 강제할 수 있다.
|
|
33
|
+
const PRESENCE_ENV_KEYS = [
|
|
34
|
+
'LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID',
|
|
35
|
+
'CLAUDE_CODE_CHILD_SESSION', 'LEERNESS_MCP_ADDRESS_EXPLICIT', 'LEERNESS_INTERNAL', 'LEERNESS_HOOK',
|
|
36
|
+
'LEERNESS_NO_SESSION_PRESENCE', 'CI', 'GITHUB_ACTIONS', 'CLAUDECODE', 'CURSOR_AGENT',
|
|
37
|
+
'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN',
|
|
38
|
+
'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
|
|
39
|
+
];
|
|
40
|
+
function normalizePresenceEnv(env, options) {
|
|
41
|
+
const source = env || {};
|
|
42
|
+
const out = { ...source };
|
|
43
|
+
const caseInsensitive = options && Object.prototype.hasOwnProperty.call(options, 'caseInsensitive')
|
|
44
|
+
? options.caseInsensitive === true
|
|
45
|
+
: process.platform === 'win32';
|
|
46
|
+
if (!caseInsensitive) return out;
|
|
47
|
+
const keys = Object.keys(source);
|
|
48
|
+
for (const canonical of PRESENCE_ENV_KEYS) {
|
|
49
|
+
const variants = keys.filter(key => key.toLowerCase() === canonical.toLowerCase());
|
|
50
|
+
if (!variants.length) continue;
|
|
51
|
+
const selected = Object.prototype.hasOwnProperty.call(source, canonical) ? canonical : variants[0];
|
|
52
|
+
for (const key of variants) delete out[key];
|
|
53
|
+
out[canonical] = source[selected];
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
28
57
|
function isValidKey(s) {
|
|
29
58
|
if (typeof s !== 'string' || !KEY_RE.test(s)) return false;
|
|
30
59
|
const folded = s.toLowerCase();
|
|
31
60
|
return !RESERVED_KEYS.has(folded) && !RESERVED_PREFIXES.some(prefix => folded.startsWith(prefix));
|
|
32
61
|
}
|
|
62
|
+
// `closedAt` 은 truthy 여부나 Date.parse()의 관대한 정규화가 아니라, 이 제품이 실제로 쓰는
|
|
63
|
+
// `Date#toISOString()` 형식과 달력 값이 정확히 일치해야 종료 증거다. 예를 들어 Date.parse는
|
|
64
|
+
// `0`과 `2024-02-30T00:00:00.000Z`도 시각으로 받아들이므로 그것만 믿으면 손상 레코드를 지운다.
|
|
65
|
+
// 손상/미래 스키마 값은 "열림"으로 단정하지도 않지만, 적어도 삭제 가능한 tombstone으로 취급하지 않는다.
|
|
66
|
+
const CANONICAL_ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
67
|
+
function confirmedCloseMs(record) {
|
|
68
|
+
const value = record && record.closedAt;
|
|
69
|
+
if (typeof value !== 'string' || !CANONICAL_ISO_UTC_RE.test(value)) return null;
|
|
70
|
+
const ms = Date.parse(value);
|
|
71
|
+
if (!Number.isFinite(ms)) return null;
|
|
72
|
+
try { return new Date(ms).toISOString() === value ? ms : null; } catch { return null; }
|
|
73
|
+
}
|
|
74
|
+
function hasConfirmedClose(record) { return confirmedCloseMs(record) !== null; }
|
|
75
|
+
|
|
76
|
+
// 구버전의 case-sensitive 저장소에서는 대소문자만 다른 주소가 서로 다른 열린 세션이었을 수 있다.
|
|
77
|
+
// 둘 이상이면 어느 하나를 canonical 주소로 합칠 근거가 없으므로 보존하고, 읽기/쓰기 표면에 충돌을 표시한다.
|
|
78
|
+
function caseVariantConflict(entries) {
|
|
79
|
+
const open = (entries || []).filter(e => e && e.record && !hasConfirmedClose(e.record));
|
|
80
|
+
if (open.length < 2) return null;
|
|
81
|
+
const variants = open.map(e => String(e.name || '')).filter(name => FILE_RE.test(name)).slice(0, 10);
|
|
82
|
+
return {
|
|
83
|
+
reason: 'ambiguous-open-case-variants',
|
|
84
|
+
count: open.length,
|
|
85
|
+
variants,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
33
88
|
// env 값의 참/거짓 — CI 계열은 `"false"`/`"0"` 을 **명시적으로** 넘기는 환경이 흔하다.
|
|
34
89
|
function isTruthyEnv(v) {
|
|
35
90
|
const t = String(v == null ? '' : v).trim().toLowerCase();
|
|
@@ -59,7 +114,7 @@ const FORBIDDEN_SIGNALS = ['CODEX_COMPANION_SESSION_ID', 'CODEX_COMPANION_TRANSC
|
|
|
59
114
|
// 물려받으므로(실측) 부모 세션으로 접히는 지금 동작을 유지한다. 독립 실행 codex 만 자기 주소를 얻는다.
|
|
60
115
|
const KEY_SOURCES = ['LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID'];
|
|
61
116
|
function keySource(env) {
|
|
62
|
-
const e = env
|
|
117
|
+
const e = normalizePresenceEnv(env);
|
|
63
118
|
// 1.36.148 (재검수 P1, 재현): child 억제가 **명시 주소보다 앞**에 있어,
|
|
64
119
|
// MCP 서버가 `CLAUDE_CODE_CHILD_SESSION=1` 을 상속하면 호출별 `sessionKey` 가 통째로 버려졌다
|
|
65
120
|
// — 1.36.145 에서 넣은 주소 전달이 조용히 무력화되고 전역 슬롯 오귀속이 돌아온다.
|
|
@@ -75,13 +130,14 @@ function keySource(env) {
|
|
|
75
130
|
// `CaseKey01ABCD` 와 `casekey01abcd` 가 **다른 주소인 척하다가 같은 파일 하나로 조용히 병합**됐다(실측 2→1).
|
|
76
131
|
// 주소를 소문자로 접어 **하나의 규칙**으로 만든다: 같은 글자면 같은 주소다(그리고 파일명과 항상 일치한다).
|
|
77
132
|
function deriveSessionKey(env) {
|
|
78
|
-
const
|
|
79
|
-
|
|
133
|
+
const e = normalizePresenceEnv(env);
|
|
134
|
+
const src = keySource(e);
|
|
135
|
+
return src ? String(e[src]).toLowerCase() : null;
|
|
80
136
|
}
|
|
81
137
|
// 1.36.132 (검수 P1): 사용자가 **명시한** 주소가 형식에 안 맞으면 조용히 다음 출처로 넘어갔다 —
|
|
82
138
|
// 오타를 알리지 않고 설정을 뒤집는 것이다. 무엇이 왜 무시됐는지 말할 수 있게 사유를 돌려준다.
|
|
83
139
|
function invalidExplicitKey(env) {
|
|
84
|
-
const v = (env
|
|
140
|
+
const v = normalizePresenceEnv(env).LEERNESS_SESSION_ID;
|
|
85
141
|
if (v === undefined || v === null || v === '') return null;
|
|
86
142
|
return isValidKey(v) ? null : String(v).slice(0, 40);
|
|
87
143
|
}
|
|
@@ -90,7 +146,7 @@ function invalidExplicitKey(env) {
|
|
|
90
146
|
// 보고했다 — codex 가 부모 env 를 통째로 물려주고, 그 codex 자신이 Claude 세션에서 시작됐기 때문이다.
|
|
91
147
|
// env 가 가리키는 것은 **체인의 최외곽**이다. 이름을 그렇게 붙이면 그 라벨은 참이 된다.
|
|
92
148
|
function detectOutermostAgent(env) {
|
|
93
|
-
const e = env
|
|
149
|
+
const e = normalizePresenceEnv(env);
|
|
94
150
|
if (e.CLAUDECODE === '1') return { agent: 'claude-code', evidence: ['CLAUDECODE'] };
|
|
95
151
|
if (!e.CLAUDECODE) {
|
|
96
152
|
for (const k of ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN']) {
|
|
@@ -109,13 +165,19 @@ function detectOutermostAgent(env) {
|
|
|
109
165
|
|
|
110
166
|
// 억제 판정은 **한 곳**에서 한다. 호출부마다 조건을 복붙하면 새 호출부가 빠진다(1.36.108 이 고친 형태).
|
|
111
167
|
function suppressionReason(env, hasHarness) {
|
|
112
|
-
const e = env
|
|
113
|
-
|
|
168
|
+
const e = normalizePresenceEnv(env);
|
|
169
|
+
// T-0110: MCP 서버도 자식 CLI를 `LEERNESS_INTERNAL=1` 로 실행한다. 그 값만 보면 호출자가
|
|
170
|
+
// 명시한 sessionKey까지 일반 내부 호출로 오인해 lifecycle handoff/close가 영원히 등록되지 않는다.
|
|
171
|
+
// 서버가 **호출별 유효 주소를 검증해 붙인 marker**만 예외로 삼는다. 단순히 부모 env에서 물려받은
|
|
172
|
+
// LEERNESS_SESSION_ID나 일반 내부 스폰은 이 marker가 없으므로 종전처럼 억제된다.
|
|
173
|
+
const addressedMcp = e.LEERNESS_MCP_ADDRESS_EXPLICIT === '1' && isValidKey(e.LEERNESS_SESSION_ID);
|
|
174
|
+
if (e.LEERNESS_INTERNAL === '1' && !addressedMcp) return 'internal';
|
|
114
175
|
if (e.LEERNESS_HOOK === '1') return 'hook';
|
|
115
176
|
// child는 부모의 LEERNESS_SESSION_ID도 그대로 상속할 수 있다. env 값만 보고 호출자가 새 주소를
|
|
116
177
|
// 지정했다고 단정하면 자식 활동이 부모 presence에 합쳐진다. 상태 MCP의 호출별 sessionKey는
|
|
117
|
-
// deriveSessionKey 경로에서 별도로
|
|
118
|
-
|
|
178
|
+
// deriveSessionKey 경로에서 별도로 귀속된다. presence 예외는 MCP 서버가 호출별 주소를 검증한
|
|
179
|
+
// lifecycle handoff/close에만 marker로 열고, 그 외 child는 계속 억제한다.
|
|
180
|
+
if (e.CLAUDE_CODE_CHILD_SESSION === '1' && !addressedMcp) return 'child-agent';
|
|
119
181
|
// ⚠ (검수 P2, 실측 재현) `e.CI` 만 보면 `CI="false"`·`CI="0"` 도 억제했다 —
|
|
120
182
|
// 명시적으로 '아니다' 라고 말한 정상 로컬 환경이 조용히 등록되지 않는다. **참인 값만** 본다.
|
|
121
183
|
if (isTruthyEnv(e.CI) || isTruthyEnv(e.GITHUB_ACTIONS)) return 'ci';
|
|
@@ -137,34 +199,44 @@ function selectPrunable(records, opts) {
|
|
|
137
199
|
const selfKey = o.selfKey || null;
|
|
138
200
|
const list = (records || []).filter(e => e && typeof e.name === 'string' && FILE_RE.test(e.name));
|
|
139
201
|
const keyOf = (e) => String(e.name).replace(/\.json$/, '');
|
|
140
|
-
const
|
|
202
|
+
const foldedKeyOf = (e) => keyOf(e).toLowerCase();
|
|
203
|
+
const foldedSelf = typeof selfKey === 'string' ? selfKey.toLowerCase() : null;
|
|
204
|
+
// 1.36.171: 1.36.132 이전 POSIX 레코드는 파일명과 payload가 혼합 대소문자일 수 있다.
|
|
205
|
+
// 정규화 뒤 같은 주소라면 신뢰하되, 실제 삭제 대상은 언제나 원래 파일명으로 돌려준다.
|
|
206
|
+
const trusted = list.filter(e => e.record && isValidKey(e.record.sessionKey)
|
|
207
|
+
&& String(e.record.sessionKey).toLowerCase() === foldedKeyOf(e));
|
|
141
208
|
const doomed = new Set();
|
|
142
|
-
//
|
|
143
|
-
|
|
209
|
+
// 이름과 키가 어긋나도 열린 기록은 다른 에이전트의 마지막 흔적일 수 있다. close/tombstone이
|
|
210
|
+
// 확인된 파일만 정리한다. 저장소 상한 때문에 **열린 동료 기록을 삭제하지 않는다**.
|
|
211
|
+
for (const e of list) {
|
|
212
|
+
if (!trusted.includes(e) && foldedKeyOf(e) !== foldedSelf && hasConfirmedClose(e.record)) doomed.add(e.name);
|
|
213
|
+
}
|
|
144
214
|
for (const e of trusted) {
|
|
145
|
-
if (
|
|
146
|
-
const c =
|
|
147
|
-
if (
|
|
215
|
+
if (foldedKeyOf(e) === foldedSelf) continue;
|
|
216
|
+
const c = confirmedCloseMs(e.record);
|
|
217
|
+
if (c !== null && nowMs && (nowMs - c) > ttl) doomed.add(e.name);
|
|
148
218
|
}
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
219
|
+
// 상한은 닫힌 tombstone에만 적용한다. 열린 기록은 라이브니스가 아니라도 충돌 회피를 위한 유일한
|
|
220
|
+
// 증거라서, 임의의 20개 제한으로 버리면 안 된다.
|
|
221
|
+
const closed = trusted.filter(e => !doomed.has(e.name) && hasConfirmedClose(e.record));
|
|
152
222
|
const activity = (e) => Math.max(Date.parse((e.record && e.record.lastHandoffAt) || '') || 0,
|
|
223
|
+
Date.parse((e.record && e.record.closedAt) || '') || 0,
|
|
153
224
|
Date.parse((e.record && e.record.openedAt) || '') || 0);
|
|
154
|
-
const ranked =
|
|
155
|
-
for (const e of ranked.slice(keep)) { if (
|
|
225
|
+
const ranked = closed.slice().sort((a, b) => activity(b) - activity(a));
|
|
226
|
+
for (const e of ranked.slice(keep)) { if (foldedKeyOf(e) !== foldedSelf) doomed.add(e.name); }
|
|
156
227
|
return [...doomed];
|
|
157
228
|
}
|
|
158
229
|
|
|
159
230
|
// 헤드라인은 **0이면 침묵**한다. "다른 세션 0건" 은 '아무도 없다' 로 읽히는데, 이 레지스트리는
|
|
160
|
-
// (a) handoff 를 안 부르는 에이전트 (b) 업그레이드 전에 시작된 세션 (c) 다른 머신 (d) MCP
|
|
231
|
+
// (a) handoff 를 안 부르는 에이전트 (b) 업그레이드 전에 시작된 세션 (c) 다른 머신 (d) sessionKey 없는 MCP 클라이언트를
|
|
161
232
|
// **구조적으로 못 본다**. 못 본 것을 없다고 단언하지 않는다.
|
|
162
233
|
// ⚠ hostId 를 모르면(둘 중 하나가 null) '같은 머신' 이라고도 '다른 머신' 이라고도 하지 않는다 — 세지 않는다.
|
|
163
234
|
function countOtherSessions(records, selfKey, selfHostId) {
|
|
235
|
+
const foldedSelf = typeof selfKey === 'string' ? selfKey.toLowerCase() : null;
|
|
164
236
|
return (records || []).filter(r => r && isValidKey(r.sessionKey)
|
|
165
|
-
&& r.sessionKey !==
|
|
237
|
+
&& String(r.sessionKey).toLowerCase() !== foldedSelf
|
|
166
238
|
&& r.hostId && selfHostId && r.hostId === selfHostId
|
|
167
|
-
&& !r
|
|
239
|
+
&& !hasConfirmedClose(r)).length;
|
|
168
240
|
}
|
|
169
241
|
|
|
170
242
|
// 같은 머신인지 **모를 수 있다** — 3상태로 답한다(검수 P2: null 을 '다른 머신' 으로 단정했다).
|
|
@@ -176,6 +248,7 @@ function hostRelation(recordHostId, selfHostId) {
|
|
|
176
248
|
module.exports = {
|
|
177
249
|
KEY_SOURCES, keySource, invalidExplicitKey,
|
|
178
250
|
SESSIONS_KEEP, SESSIONS_CLOSED_TTL_MS, KEY_RE, FILE_RE, RESERVED_KEYS, RESERVED_PREFIXES, FORBIDDEN_SIGNALS,
|
|
179
|
-
|
|
251
|
+
PRESENCE_ENV_KEYS, normalizePresenceEnv, isValidKey, confirmedCloseMs, hasConfirmedClose, caseVariantConflict,
|
|
252
|
+
isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
|
|
180
253
|
selectPrunable, countOtherSessions, hostRelation,
|
|
181
254
|
};
|
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,15 @@
|
|
|
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 && 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",
|
|
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/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",
|
|
59
|
+
"test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
|
|
58
60
|
"test:smoke": "node ./scripts/smoke.js",
|
|
59
61
|
"lint": "node ./scripts/lint.js",
|
|
60
62
|
"prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
|
package/scripts/e2e.js
CHANGED
|
@@ -15029,6 +15029,7 @@ total++;
|
|
|
15029
15029
|
if (!s1 || !s2 || s1.started === s2.started) bad.push('①MCP 두 세션이 같은 run 을 받음 — 판별 불가');
|
|
15030
15030
|
else {
|
|
15031
15031
|
if (!rec || rec.recorded !== s1.started) bad.push(`①MCP: A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rec && rec.recorded})`);
|
|
15032
|
+
if (!ver || ver.result !== 'pass' || !ver.run || ver.run.verification_result !== 'pass') bad.push(`①MCP: B verify pass 응답이 없거나 실패(${JSON.stringify(ver)})`);
|
|
15032
15033
|
const allowed = ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed;
|
|
15033
15034
|
if (allowed) bad.push('①MCP: 일하지 않은 세션이 완료 허용을 받음 — 증거 게이트가 뒤집힘');
|
|
15034
15035
|
}
|
|
@@ -15345,6 +15346,7 @@ total++;
|
|
|
15345
15346
|
if (!s1 || !s2 || s1.started === s2.started) bad.push('②자식 env 에서 두 세션이 같은 run — 판별 불가');
|
|
15346
15347
|
else {
|
|
15347
15348
|
if (!rec || rec.recorded !== s1.started) bad.push(`②자식 env 에서 증거가 엉뚱한 run 에 붙음(${rec && rec.recorded})`);
|
|
15349
|
+
if (!ver || ver.result !== 'pass' || !ver.run || ver.run.verification_result !== 'pass') bad.push(`②자식 env verify pass 응답이 없거나 실패(${JSON.stringify(ver)})`);
|
|
15348
15350
|
if (ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed) bad.push('②자식 env 에서 놀던 세션이 완료 허용을 받음');
|
|
15349
15351
|
}
|
|
15350
15352
|
dbg.mcpChild = { A: s1 && s1.started, recordedTo: rec && rec.recorded,
|