leerness 1.36.300 → 1.36.301

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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.301 - 2026-10-14
4
+
5
+ - Changed: public CI runs its long work in parallel shards (T-0359). 1.36.300's CI run (38332288454) took five hours: each Windows gate job (199-300 min) ran e2e.js after `test:commands`, and the Windows fast job took 46 min (79 min in 1.36.299's run 38193803239).
6
+ - `scripts/ci-shard.js` splits the work. Every shard of a job computes the same split from the same inputs: the units in source order, weighted by measured seconds (`scripts/ci-shard-weights.json`), placed largest first on the least-loaded shard. So the shards of one job run each unit exactly once.
7
+ - e2e.js: each top-level case now starts with `if (e2eShard.take('<key>')) {`, with its `total++;` inside. `LEERNESS_E2E_SHARD=i/k` runs shard i of k; without it every case runs, as before.
8
+ - Cases that mention the shared fixture project or its helpers (`tmp`, `run`, `dsPath`, `dsText`, `secretFile`; 104 of 423, a text match that errs toward grouping) run together in one shard, in file order, because one case leaves state for the next. A first version without this group failed three of them on Linux.
9
+ - The cases start after the line `// e2e-shard: cases start here`. An e2e run fails when a guard is malformed or repeated, a guard is not reached exactly once, or code below that line and outside a case opens a block, a control-flow block or an immediately invoked function, or counts a case. Whatever wraps a case that lost its guard, its `total++` is outside a guard. Each shard still runs the smoke preamble above the line and the run-wide checks at the end (runtime DEP0190 warnings, repository isolation); those checks end the file, and each of their counters is marked `// e2e-shard: run-wide check`.
10
+ - `test:commands` and `test:fast` run as leaves: `node ./scripts/ci-shard.js npm <script> i/k` expands `npm run` steps and runs that shard's `node` leaves in order. It refuses any other step shape and any script with a `pre` or `post` lifecycle script. Unlike an `&&` chain, it runs every leaf of its shard and then fails if any failed.
11
+ - The workflow: e2e in 4 shards on each Windows Node version and 2 on each Ubuntu one; `test:commands` in 2 shards on Windows; the Windows fast job in 3; the other gate steps in one `gate core` job per configuration. The OS/Node configurations and steps are those of 1.36.300.
12
+ - From the per-case times of run 38193803239, an e2e shard should take about 56 min on Windows Node 18 (226 min unsharded), 43-48 min on Node 20-24 and at most 25 min on Ubuntu, plus job setup. This release's CI run is the first measurement.
13
+ - CONTRIBUTING.md describes the case form and how to refresh weights (`LEERNESS_E2E_TIMES`, `LEERNESS_CI_SHARD_TIMES`, `ci-shard.js weights`).
14
+ - Fixed: a command waiting for a workspace migration failed when the migration took longer than the lock wait (T-0348). Such a migration is another command moving a legacy `.harness` workspace to `.leerness`.
15
+ - Measured on this PC: a first migration of 400 files of 8 KiB took 13.1-14.7 s on Windows with Node 20.20.2 and 22.23.3, against 4.7 s on 26.3.0. That is past the 10 s that waiting commands allowed.
16
+ - Most of that time went to reading each fresh copy back to verify it. The first read of a file just written cost about 10 ms on Node 20 and 22 there. Copying took 0.4 s.
17
+ - The migrating process now refreshes the lock file's modification time while it works, at most every 500 ms: in every loop over the migrated files (the walk and its sorting, mapping, collision and destination checks, copies, hashes, comparisons, reference rewrites and rollback). Not covered: one built-in call whose time grows with the file count, such as listing a folder of a million files; at the size of real legacy workspaces such calls take milliseconds. Waiting commands keep waiting while the lock changes. With every wrappable file call of a migration of 60 files slowed by 30 ms, the lock never stays unchanged for 1.5 s; with 15 ms per call on 80 files, the longest unchanged time measured was 588 ms.
18
+ - The list of files to copy no longer searches the already-present files once per file. That search grew with the square of the file count: 11.6 s for 60,000 files on this PC, now 30 ms.
19
+ - `LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS` (default 10 s) is now the longest wait without progress. A lock left by a process that died stops changing, and its waiters still fail after that time. The whole wait is capped at 15 minutes. A positive value below 1.5 s counts as 1.5 s, three refresh intervals, so a short wait does not expire between two refreshes; 0 still does not wait. The refresh interval and the waits are measured on a monotonic clock, so setting the system clock back or forward neither stops the refreshes nor ends a wait.
20
+ - On this PC, `simultaneous-first-migrations-converge-with-lock-wait` in `scripts/workspace-dir-migration-probe.js` now passes; before, it failed here, while the GitHub runner passed it.
21
+ - `docs/runtime-compatibility-api.md` describes the wait.
22
+ - Fixed: `leerness readme sync` counted only column-0 `total++;` lines for a README e2e badge, so a case whose counter sits inside a shard guard was not counted. Projects without guards count as before.
23
+ - Tests (T-0348): `scripts/workspace-migration-heartbeat-probe.js` (`npm run test:migration-heartbeat`, in `test`, `test:core` and `test:fast`) has thirteen cases:
24
+ - every loop of the migration functions must refresh the lock unless it waits for another process's lock or walks a fixed list (a check of the source: the CPU-only loops cannot be slowed from outside);
25
+ - the list of files to copy for 60,000 files must take under 2 s;
26
+ - a waiter set to 100 ms must still outlast a migration slowed to 40 ms per copy, and a positive wait is at least 1.5 s while 0 does not wait (fake clock);
27
+ - the owner's wall clock set back 30 s after its tenth copy must not stop the refreshes, and a waiter's wall clock jumping 60 s forward must not end a 5 s wait;
28
+ - a migration with every wrappable file call slowed (lstat and stat are left alone: the runtime layout refuses a wrapped one): the lock must not stay unchanged for 1.5 s;
29
+ - migrations slowed in one step at a time, 40 ms per copy, per reference rewrite or per destination check, each with another migration waiting 1.5 s for it;
30
+ - a writer waiting 1 s on a lock refreshed for 3.5 s and then released;
31
+ - a lock that never changes, and one that stops changing after 4 s.
32
+ - On the old code it passes only the never-changing case (1 of 13). With the migrating process's refresh turned off it passes 7 of 13: the six cases with a slowed or clock-shifted migration fail. Without the 1.5 s minimum, the two short-wait cases fail; with the wall clock instead of the monotonic one, both clock cases fail in 5 of 5 runs.
33
+ - Tests (T-0359):
34
+ - `scripts/ci-shard-probe.js` (`npm run test:ci-shard`, in `test`, `test:core` and `test:fast`), 13 checks: the split is a deterministic partition for 1-8 shards; every leaf of the sharded npm scripts and every e2e case runs in exactly one shard for 1-6 shards; the shared-fixture cases stay in one shard; the guard parser, the runtime check, the npm step parser, the leaf runner and the workflow's shard lists each catch their broken inputs; `readme sync` counts guarded cases. 24 mutants of the runner and the badge count each fail it.
35
+ - `scripts/release-runtime-probe.js` reads the workflow's shard lists: Windows Node 24 must list every e2e shard. A dropped shard, a mismatched shard count, a missing shard variable and a changed e2e command each fail it.
36
+ - Linux (Docker node:20): HEAD's e2e.js 468/468 and this release's 469/469 print the same result labels apart from the new check. The final version's 3 shards pass (249/150/162), and every case label falls in exactly one shard. The first reviewed version also passed in 3 (259/149/153) and in 2 shards (313/202).
37
+ - Windows (this PC, Node 26.3.0, 4 shards in parallel, first reviewed version): 223/122/136/126, and the labels match the Windows Node 24 e2e step of run 38332288454. Since then e2e.js changed only by comments: the cases-start line and the run-wide marks.
38
+ - Reviewed by codex gpt-6.1-sol and gpt-6-astra over nine rounds; each finding got a test that fails on the reviewed code (rounds 4-9 by owner decision).
39
+
3
40
  ## 1.36.300 - 2026-10-13
4
41
 
5
42
  - Fixed: `leerness lazy detect --auto-track` registered TODOs that a task already covered (T-0345).
package/CONTRIBUTING.md CHANGED
@@ -35,6 +35,8 @@ Prefer **behavioral** tests over source-grep guards. A source-grep guard asserts
35
35
 
36
36
  When you add a guard, construct the **discriminating case**: an input where the old code passes and only the new check catches it. If you cannot construct one, the check may not be measuring what you think.
37
37
 
38
+ Each top-level case in `scripts/e2e.js` starts with `if (e2eShard.take('<unique key>')) {` and counts itself with `total++;` inside the block. CI runs that file, `test:commands` and `test:fast` in parallel shards (`scripts/ci-shard.js`): every shard computes the same split from `scripts/ci-shard-weights.json`, so the shards of one job run each case and leaf exactly once. A top-level block without the guard, a repeated key, or a guard that is not reached exactly once fails the e2e run. `node ./scripts/ci-shard.js plan e2e 4` shows the split; a run with `LEERNESS_E2E_TIMES=<file>` (e2e) or `LEERNESS_CI_SHARD_TIMES=<file>` (npm leaves) writes measured seconds, and `node ./scripts/ci-shard.js weights e2e <file>` stores them.
39
+
38
40
  ## Release
39
41
 
40
42
  `npm test` (gate) → bump `VERSION` in `bin/leerness.js` **and** `version` in `package.json` (selftest asserts they match) → CHANGELOG entry → commit → publish → push.
package/README.md CHANGED
@@ -195,7 +195,7 @@ MIT
195
195
  <!-- leerness:project-readme:start -->
196
196
  ## Leerness Project Harness
197
197
 
198
- 이 프로젝트는 Leerness v1.36.300 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
198
+ 이 프로젝트는 Leerness v1.36.301 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
199
199
 
200
200
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
201
201
 
@@ -249,7 +249,7 @@ leerness memory restore decision <date|title>
249
249
 
250
250
  ### MCP server (외부 AI 통합)
251
251
 
252
- Leerness v1.36.300는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
252
+ Leerness v1.36.301는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
253
253
 
254
254
  ```jsonc
255
255
  // 카테고리별
@@ -270,7 +270,7 @@ Leerness v1.36.300는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
270
270
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
271
271
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
272
272
 
273
- 현재 사용 중인 하네스 버전: **1.36.300**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
273
+ 현재 사용 중인 하네스 버전: **1.36.301**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
274
274
 
275
275
  ### 성능 가이드
276
276
 
@@ -308,7 +308,7 @@ leerness release pack --close --auto-main-push
308
308
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
309
309
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
310
310
 
311
- Last synced by Leerness v1.36.300: 2026-09-30
311
+ Last synced by Leerness v1.36.301: 2026-09-30
312
312
  <!-- leerness:project-readme:end -->
313
313
  GitHub 로그인 계정 확인용 서버 어댑터: [설정·연결·승인과의 구분](docs/github-login.md). 실제 앱 등록/브라우저 세션 연결과 대상별 승인은 후속입니다.
314
314
 
package/bin/leerness.js CHANGED
@@ -115,7 +115,7 @@ const {
115
115
  migrateLegacyWorkspace,
116
116
  } = require('../lib/workspace-dir');
117
117
 
118
- const VERSION = '1.36.300';
118
+ const VERSION = '1.36.301';
119
119
 
120
120
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
121
121
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -1582,8 +1582,11 @@ function syncReadme(root, versionOverride = null) {
1582
1582
  const e2ePath = path.join(root, 'scripts', 'e2e.js');
1583
1583
  if (exists(e2ePath)) {
1584
1584
  // 1.9.271: e2e 테스트 수 = `total++;` 블록 + `run(...)` 호출 (run 헬퍼가 내부에서 total++ 수행) → 실제 결과와 일치.
1585
+ // T-0359: leerness's own e2e.js counts each case inside its CI shard guard (`if (e2eShard.take('<key>')) {`
1586
+ // followed by ` total++;`). Those count too, so guarding the cases does not shrink the estimate.
1585
1587
  const body = read(e2ePath);
1586
- const total = (body.match(/^total\+\+;/gm) || []).length + (body.match(/^run\(/gm) || []).length;
1588
+ const total = (body.match(/^total\+\+;/gm) || []).length + (body.match(/^run\(/gm) || []).length
1589
+ + (body.match(/^if \(e2eShard\.take\('[^'\r\n]+'\)\) \{\r?\n {2}total\+\+;/gm) || []).length;
1587
1590
  if (total > 0) {
1588
1591
  updated = updated.replace(/badge\/e2e-(\d+)%2F(\d+)-success/g, `badge/e2e-${total}%2F${total}-success`);
1589
1592
  }
@@ -96,10 +96,14 @@ New writer operations may read-only wait for an existing regular, single-linked
96
96
  workspace migration lock before classifying ownership. They reclassify after the peer
97
97
  finishes; waiting never grants lock ownership or permission to overwrite a conflict.
98
98
  The existing migration wait budget defaults to 10 seconds, is configurable through
99
- `LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS`, and is clamped to 0–60 seconds. Expired or
100
- non-regular/linked lock observations block with `workspace_dir_migration_locked`; no
101
- lock is read, reclaimed or deleted by admission. The budget bounds contention waiting,
102
- not unrelated Git/filesystem I/O. Public `state compatibility` remains immediate.
99
+ `LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS`, and is clamped to 0–60 seconds; a positive value below 1.5 seconds
100
+ counts as 1.5 seconds (three refresh intervals), and 0 does not wait. It is the longest
101
+ wait without progress: the migrating process refreshes the lock file's modification time while
102
+ it works (at most every 500 ms), and a waiter keeps waiting while the lock's size or modification
103
+ time changes, up to 15 minutes in all. A lock that does not change for the budget, such as one
104
+ left by a process that died, and non-regular/linked lock observations block with
105
+ `workspace_dir_migration_locked`; no lock is read, reclaimed or deleted by admission. The budget
106
+ bounds contention waiting, not unrelated Git/filesystem I/O. Public `state compatibility` remains immediate.
103
107
  An observed non-workspace denial is terminal even if a peer subsequently removes its
104
108
  descriptor. A conflict snapshot gets at most one extra classification when no lock is
105
109
  seen, covering a peer that completed between observations without accepting persistent
@@ -388,13 +388,17 @@ function createRuntimeWriterAdmission(root = process.cwd(), lockWaitMs) {
388
388
  const configured = lockWaitMs === undefined ? envValue('LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS') : lockWaitMs;
389
389
  const parsed = configured === '' || configured === undefined ? NaN : Number(configured);
390
390
  const budget = Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
391
- const deadline = started + budget;
391
+ // T-0348: the budget is the longest wait without progress: the migrating process refreshes the lock file's
392
+ // modification time as it works (lib/workspace-dir.js), and a lock that stops changing fails after it.
393
+ const lockWait = W.migrationLockWait(lock, budget, W.MIGRATION_LOCK_MAX_WAIT_MS, clock);
394
+ let lastLockStat = null;
392
395
  const contention = () => {
393
396
  waitedForLock = true;
394
- if (clock() >= deadline) fail('workspace_dir_migration_locked');
397
+ if (lockWait.expired(lastLockStat)) fail('workspace_dir_migration_locked');
395
398
  };
396
399
  const pending = () => {
397
400
  const stat = statMaybe(lock);
401
+ lastLockStat = stat;
398
402
  if (!stat) return false;
399
403
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) fail('workspace_dir_migration_locked');
400
404
  return true;
@@ -403,7 +407,7 @@ function createRuntimeWriterAdmission(root = process.cwd(), lockWaitMs) {
403
407
  for (;;) {
404
408
  while (pending()) {
405
409
  contention();
406
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, Math.min(25, deadline - clock())));
410
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, Math.min(25, lockWait.remainingMs())));
407
411
  }
408
412
  const reader = createRuntimeCompatibilityReader(location.root);
409
413
  const report = reader();
@@ -82,13 +82,15 @@ function _inspectDir(root, name) {
82
82
  try { entries = fs.readdirSync(abs); }
83
83
  catch (error) { inspectionError = error.code || 'unknown'; }
84
84
  }
85
- const markers = entries.filter((entry) => STRONG_MARKERS.has(entry));
85
+ // T-0348 review round 8: a migration inspects the workspace again after taking its lock, so these per-entry
86
+ // filters refresh the lock too (a no-op outside a migration).
87
+ const markers = entries.filter((entry) => { _beat(); return STRONG_MARKERS.has(entry); });
86
88
  const live = markers.length > 0;
87
89
  const substrateEntries = name === CANONICAL_WORKSPACE_DIR
88
- ? entries.filter((entry) => SUBSTRATE_TOP_LEVEL.has(entry))
90
+ ? entries.filter((entry) => { _beat(); return SUBSTRATE_TOP_LEVEL.has(entry); })
89
91
  : [];
90
92
  const nonSubstrateEntries = name === CANONICAL_WORKSPACE_DIR
91
- ? entries.filter((entry) => !SUBSTRATE_TOP_LEVEL.has(entry))
93
+ ? entries.filter((entry) => { _beat(); return !SUBSTRATE_TOP_LEVEL.has(entry); })
92
94
  : entries.slice();
93
95
  const substrateOnly = name === CANONICAL_WORKSPACE_DIR
94
96
  && isDirectory && !live && substrateEntries.length > 0 && nonSubstrateEntries.length === 0;
@@ -205,9 +207,11 @@ function legacyWorkspacePath(root, ...parts) {
205
207
  }
206
208
 
207
209
  function _hashFile(file) {
210
+ _beat();
208
211
  const h = crypto.createHash('sha256');
209
212
  let body;
210
213
  for (let attempt = 0; ; attempt++) {
214
+ _beat();
211
215
  try { body = fs.readFileSync(file); break; }
212
216
  catch (error) {
213
217
  const transient = error && ['EBUSY', 'EPERM', 'EACCES'].includes(error.code);
@@ -222,6 +226,7 @@ function _hashFile(file) {
222
226
  function _walkFiles(base) {
223
227
  const out = [];
224
228
  function visit(rel) {
229
+ _beat();
225
230
  const abs = rel ? path.join(base, rel) : base;
226
231
  const stat = fs.lstatSync(abs);
227
232
  if (stat.isSymbolicLink()) {
@@ -232,7 +237,10 @@ function _walkFiles(base) {
232
237
  );
233
238
  }
234
239
  if (stat.isDirectory()) {
235
- for (const entry of fs.readdirSync(abs).sort()) visit(rel ? path.join(rel, entry) : entry);
240
+ // Review round 7: sorting a huge folder is CPU work before the first visit; the comparator refreshes the lock.
241
+ // `a < b` on strings compares UTF-16 code units, the order of the default sort.
242
+ const entries = fs.readdirSync(abs).sort((a, b) => { _beat(); return a < b ? -1 : a > b ? 1 : 0; });
243
+ for (const entry of entries) visit(rel ? path.join(rel, entry) : entry);
236
244
  return;
237
245
  }
238
246
  if (stat.isFile()) out.push({ rel, abs, size: stat.size, hash: _hashFile(abs) });
@@ -253,6 +261,7 @@ function _mappedRelative(rel) {
253
261
  }
254
262
 
255
263
  function _copyFile(src, dst) {
264
+ _beat();
256
265
  fs.mkdirSync(path.dirname(dst), { recursive: true });
257
266
  fs.copyFileSync(src, dst);
258
267
  }
@@ -285,6 +294,8 @@ function _safeRemoveCanonicalDir(root, target) {
285
294
  }
286
295
 
287
296
  function _rewriteLegacyReference(file) {
297
+ // T-0348 review round 1: the rewrite pass after the copy is long for a large workspace; it refreshes the lock too.
298
+ _beat();
288
299
  let raw;
289
300
  try { raw = fs.readFileSync(file); } catch { return false; }
290
301
  if (raw.length > 4 * 1024 * 1024 || raw.includes(0)) return false;
@@ -363,18 +374,21 @@ function _isTransientLockFsError(error) {
363
374
  }
364
375
 
365
376
  function _removeOwnedLockFile(lockFile) {
366
- const deadline = Date.now() + 2000;
377
+ const deadline = _monotonicMs() + 2000;
367
378
  while (true) {
368
379
  try { fs.unlinkSync(lockFile); return; }
369
380
  catch (error) {
370
381
  if (error && error.code === 'ENOENT') return;
371
- if (!_isTransientLockFsError(error) || Date.now() >= deadline) return;
372
- _sleepSync(Math.min(25, Math.max(1, deadline - Date.now())));
382
+ if (!_isTransientLockFsError(error) || _monotonicMs() >= deadline) return;
383
+ _sleepSync(Math.min(25, Math.max(1, deadline - _monotonicMs())));
373
384
  }
374
385
  }
375
386
  }
376
387
 
377
388
  function _destinationSnapshot(file) {
389
+ // T-0348 review round 2: the check before the copy finds most destinations missing and hashes nothing, so it
390
+ // refreshes the lock itself.
391
+ _beat();
378
392
  if (!fs.existsSync(file)) return { kind: 'missing', hash: null };
379
393
  const stat = fs.lstatSync(file);
380
394
  if (stat.isSymbolicLink()) {
@@ -384,6 +398,20 @@ function _destinationSnapshot(file) {
384
398
  return { kind: 'file', hash: _hashFile(file) };
385
399
  }
386
400
 
401
+ // The destinations to copy: every mapped file except those already identical there. T-0348 review round 3: this used
402
+ // to search the skipped list once per file, quadratic in the file count (60,000 skipped files took 33 s in the
403
+ // reviewer's reproduction, past a waiter's 10 s); a Set keeps it linear, and each file refreshes the lock.
404
+ function _copiedFiles(mapped, skippedFiles) {
405
+ const skipped = new Set(skippedFiles);
406
+ const out = [];
407
+ for (const item of mapped) {
408
+ _beat();
409
+ const rel = item.mappedRel.replace(/\\/g, '/');
410
+ if (!skipped.has(rel)) out.push(rel);
411
+ }
412
+ return out;
413
+ }
414
+
387
415
  function _sameDestinationSnapshot(a, b) {
388
416
  return !!a && !!b && a.kind === b.kind && a.hash === b.hash;
389
417
  }
@@ -395,6 +423,58 @@ function _migrationLockWaitMs(opts = {}) {
395
423
  return Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
396
424
  }
397
425
 
426
+ // T-0348: a first migration can take longer than the lock wait. On Windows with Node 20 and 22, 400 files took
427
+ // 11-15 s here, mostly reading each fresh copy back to verify it. The commands waiting for it then failed while it
428
+ // was still working. So the owner refreshes the lock file's modification time while it works, at most every
429
+ // 500 ms, from each step of its walk, checks, copies, hashes, rewrites and rollback. A waiter keeps waiting while that
430
+ // time moves. The lock wait (LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS, default 10 s, at least 1.5 s when set) is now
431
+ // the longest wait without progress; a lock left by a process that died stops moving, and its waiters still fail
432
+ // after it. The whole wait is capped at 15 min.
433
+ const MIGRATION_LOCK_MAX_WAIT_MS = 15 * 60 * 1000;
434
+ const _MIGRATION_HEARTBEAT_MS = 500;
435
+ // Review round 5: elapsed times use a monotonic clock (as the runtime-layout admission does). With the wall clock, a
436
+ // clock set back stopped the refreshes until it caught up, and one set forward ended a wait at once.
437
+ const _monotonicMs = () => Number(process.hrtime.bigint() / 1000000n);
438
+ let _migrationHeartbeat = null;
439
+ function _beat() { if (_migrationHeartbeat) _migrationHeartbeat(); }
440
+ function _startMigrationHeartbeat(lockFd) {
441
+ let last = -Infinity;
442
+ _migrationHeartbeat = () => {
443
+ const now = _monotonicMs();
444
+ if (now - last < _MIGRATION_HEARTBEAT_MS) return;
445
+ last = now;
446
+ // The stored time only has to change; it is the wall clock, whatever it says.
447
+ try { const at = new Date(); fs.futimesSync(lockFd, at, at); } catch {}
448
+ };
449
+ _migrationHeartbeat();
450
+ }
451
+ function _stopMigrationHeartbeat() { _migrationHeartbeat = null; }
452
+
453
+ // The wait of a process that found the migration lock taken. `expired()` observes the lock once and is true when
454
+ // its size and modification time (or its absence) have not changed for `idleMs`, or `capMs` passed since the wait
455
+ // began. A caller that has just read the lock's bigint lstat passes it (or null when the lock was missing), so the
456
+ // lock is not read twice. `remainingMs()` is the time until it would expire without a further change.
457
+ // Review round 4: the owner refreshes at most every 500 ms, so a shorter wait could expire between two refreshes of
458
+ // a migration that is progressing. A positive wait is therefore at least three refresh intervals; 0 still means
459
+ // "do not wait".
460
+ const MIGRATION_LOCK_MIN_WAIT_MS = 3 * _MIGRATION_HEARTBEAT_MS;
461
+ function migrationLockWait(lockFile, waitMs, capMs = MIGRATION_LOCK_MAX_WAIT_MS, clock = _monotonicMs) {
462
+ const idleMs = waitMs > 0 ? Math.max(waitMs, MIGRATION_LOCK_MIN_WAIT_MS) : 0;
463
+ const startedAt = clock();
464
+ let lastSignature, lastChangeAt = startedAt;
465
+ const signatureOf = st => (st ? `${st.size}:${st.mtimeNs}` : 'absent');
466
+ const signature = () => { try { return signatureOf(fs.lstatSync(lockFile, { bigint: true })); } catch { return 'absent'; } };
467
+ return {
468
+ startedAt,
469
+ expired(observed) {
470
+ const now = clock(), current = observed === undefined ? signature() : signatureOf(observed);
471
+ if (current !== lastSignature) { lastSignature = current; lastChangeAt = now; }
472
+ return now - lastChangeAt >= idleMs || now - startedAt >= capMs;
473
+ },
474
+ remainingMs() { return Math.max(0, Math.min(lastChangeAt + idleMs, startedAt + capMs) - clock()); },
475
+ };
476
+ }
477
+
398
478
  function _newMigrationReport(state, opts = {}) {
399
479
  const at = opts.at || new Date().toISOString();
400
480
  const version = String(opts.version || 'unknown');
@@ -436,8 +516,8 @@ function migrateLegacyWorkspace(root, opts = {}) {
436
516
 
437
517
  const lockWaitMs = _migrationLockWaitMs(opts);
438
518
  const lockFile = path.join(initial.root, '.leerness-workspace-migration.lock');
439
- const startedAt = Date.now();
440
- const deadline = startedAt + lockWaitMs;
519
+ const startedAt = _monotonicMs();
520
+ const lockWait = migrationLockWait(lockFile, lockWaitMs);
441
521
  let lockFd = null;
442
522
  let waited = false;
443
523
 
@@ -450,31 +530,33 @@ function migrateLegacyWorkspace(root, opts = {}) {
450
530
  report.blockedReason = 'workspace-dir-migration-failed';
451
531
  report.errors.push(error && error.message ? error.message : String(error));
452
532
  report.waitedForLock = waited;
453
- report.lockWaitedMs = waited ? Math.max(0, Date.now() - startedAt) : 0;
533
+ report.lockWaitedMs = waited ? Math.max(0, _monotonicMs() - startedAt) : 0;
454
534
  return report;
455
535
  }
456
536
  waited = true;
457
- if (Date.now() >= deadline) {
537
+ if (lockWait.expired()) {
458
538
  const report = _newMigrationReport(initial, opts);
459
539
  report.blocked = true;
460
540
  report.blockedReason = 'workspace-dir-migration-locked';
461
541
  report.errors.push(`another workspace migration still owns: ${lockFile}`);
462
542
  report.waitedForLock = true;
463
- report.lockWaitedMs = Math.max(0, Date.now() - startedAt);
543
+ report.lockWaitedMs = Math.max(0, _monotonicMs() - startedAt);
464
544
  return report;
465
545
  }
466
- _sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
546
+ _sleepSync(Math.min(50, Math.max(1, lockWait.remainingMs())));
467
547
  }
468
548
  }
469
549
 
550
+ _startMigrationHeartbeat(lockFd);
470
551
  try {
471
552
  const report = _migrateLegacyWorkspace(initial.root, { ...opts, [_MIGRATION_LOCK_OWNED]: true });
472
553
  if (waited) {
473
554
  report.waitedForLock = true;
474
- report.lockWaitedMs = Math.max(report.lockWaitedMs || 0, Date.now() - startedAt);
555
+ report.lockWaitedMs = Math.max(report.lockWaitedMs || 0, _monotonicMs() - startedAt);
475
556
  }
476
557
  return report;
477
558
  } finally {
559
+ _stopMigrationHeartbeat();
478
560
  try { fs.closeSync(lockFd); } catch {}
479
561
  _removeOwnedLockFile(lockFile);
480
562
  }
@@ -504,8 +586,8 @@ function _migrateLegacyWorkspace(root, opts = {}) {
504
586
  // applying any conflict verdict; otherwise simultaneous first handoffs race
505
587
  // against an intermediate state and some fail before reaching the lock.
506
588
  if (!dryRun && !migrationLockOwned && fs.existsSync(lockFile)) {
507
- const peerStartedAt = Date.now();
508
- const peerDeadline = peerStartedAt + lockWaitMs;
589
+ const peerStartedAt = _monotonicMs();
590
+ const peerWait = migrationLockWait(lockFile, lockWaitMs);
509
591
  report.waitedForLock = true;
510
592
  while (fs.existsSync(lockFile)) {
511
593
  const latest = inspectWorkspace(state.root);
@@ -515,18 +597,21 @@ function _migrateLegacyWorkspace(root, opts = {}) {
515
597
  report.alreadyCanonical = true;
516
598
  report.backupDir = fs.existsSync(path.join(state.root, LEGACY_BACKUP_WORKSPACE_DIR))
517
599
  ? path.join(state.root, LEGACY_BACKUP_WORKSPACE_DIR) : null;
518
- report.lockWaitedMs = Math.max(0, Date.now() - peerStartedAt);
600
+ report.lockWaitedMs = Math.max(0, _monotonicMs() - peerStartedAt);
519
601
  return report;
520
602
  }
521
- if (Date.now() >= peerDeadline) {
522
- report.lockWaitedMs = Math.max(0, Date.now() - peerStartedAt);
603
+ if (peerWait.expired()) {
604
+ report.lockWaitedMs = Math.max(0, _monotonicMs() - peerStartedAt);
523
605
  return block('workspace-dir-migration-locked', `another workspace migration still owns: ${lockFile}`);
524
606
  }
525
- _sleepSync(Math.min(50, Math.max(1, peerDeadline - Date.now())));
607
+ _sleepSync(Math.min(50, Math.max(1, peerWait.remainingMs())));
526
608
  }
527
609
  // The peer released without completing (normally after rollback). Start a
528
610
  // fresh inspection instead of trusting the intermediate snapshot above.
529
- return migrateLegacyWorkspace(state.root, { ...opts, lockWaitMs: Math.max(0, peerDeadline - Date.now()) });
611
+ // T-0348: what is left of the wait, at most the full wait without progress; observing the release first counts
612
+ // it as progress, while the 15-minute cap still applies.
613
+ peerWait.expired();
614
+ return migrateLegacyWorkspace(state.root, { ...opts, lockWaitMs: Math.min(lockWaitMs, peerWait.remainingMs()) });
530
615
  }
531
616
 
532
617
  // The initial inspection and the lock existence check are not one atomic
@@ -582,9 +667,13 @@ function _migrateLegacyWorkspace(root, opts = {}) {
582
667
  return block(error.code === 'E_WORKSPACE_DIR_SYMLINK' ? 'workspace-dir-symlink' : 'workspace-dir-read-failed', error.message);
583
668
  }
584
669
 
585
- const mapped = sourceFiles.map((item) => ({ ...item, mappedRel: _mappedRelative(item.rel) }));
670
+ // T-0348 review round 6: every loop over the migrated files refreshes the lock, the CPU-only ones too (mapping
671
+ // and the collision check took 3.2 s for 120,000 long paths in the reviewer's reproduction). The heartbeat
672
+ // probe checks that each per-file loop of this function calls _beat().
673
+ const mapped = sourceFiles.map((item) => { _beat(); return { ...item, mappedRel: _mappedRelative(item.rel) }; });
586
674
  const mappedDestinations = new Map();
587
675
  for (const item of mapped) {
676
+ _beat();
588
677
  const rel = item.mappedRel.replace(/\\/g, '/');
589
678
  const key = process.platform === 'win32' ? rel.toLowerCase() : rel;
590
679
  if (mappedDestinations.has(key)) {
@@ -595,6 +684,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
595
684
  mappedDestinations.set(key, item.rel.replace(/\\/g, '/'));
596
685
  }
597
686
  for (const item of mapped) {
687
+ _beat();
598
688
  if (item.mappedRel !== item.rel) report.mappedFiles.push({ from: item.rel.replace(/\\/g, '/'), to: item.mappedRel.replace(/\\/g, '/') });
599
689
  const dst = path.join(state.canonical.abs, item.mappedRel);
600
690
  let snapshot;
@@ -615,6 +705,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
615
705
  else report.conflicts.push(item.mappedRel.replace(/\\/g, '/'));
616
706
  }
617
707
  const unsafeTypeConflict = mapped.some((item) => {
708
+ _beat();
618
709
  const dst = path.join(state.canonical.abs, item.mappedRel);
619
710
  if (!fs.existsSync(dst)) return false;
620
711
  try {
@@ -630,9 +721,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
630
721
  `destination has ${report.conflicts.length} conflicting file(s)`);
631
722
  }
632
723
 
633
- report.copiedFiles = mapped
634
- .filter((item) => !report.skippedFiles.includes(item.mappedRel.replace(/\\/g, '/')))
635
- .map((item) => item.mappedRel.replace(/\\/g, '/'));
724
+ report.copiedFiles = _copiedFiles(mapped, report.skippedFiles);
636
725
  report.overwrittenFiles = report.conflicts.slice();
637
726
  if (dryRun) return report;
638
727
 
@@ -645,8 +734,8 @@ function _migrateLegacyWorkspace(root, opts = {}) {
645
734
  const canonicalExisted = state.canonical.exists;
646
735
  let legacyRenamed = false;
647
736
  try {
648
- const lockDeadline = Date.now() + lockWaitMs;
649
- const lockStartedAt = Date.now();
737
+ const lockStartedAt = _monotonicMs();
738
+ const lockWait = lockFd === null ? migrationLockWait(lockFile, lockWaitMs) : null;
650
739
  while (lockFd === null) {
651
740
  try { lockFd = fs.openSync(lockFile, 'wx'); }
652
741
  catch (error) {
@@ -661,16 +750,17 @@ function _migrateLegacyWorkspace(root, opts = {}) {
661
750
  report.dstExists = true;
662
751
  report.alreadyCanonical = true;
663
752
  report.backupDir = fs.existsSync(sourceBackup) ? sourceBackup : null;
664
- report.lockWaitedMs = Math.max(0, Date.now() - lockStartedAt);
753
+ report.lockWaitedMs = Math.max(0, _monotonicMs() - lockStartedAt);
665
754
  return report;
666
755
  }
667
- if (Date.now() >= lockDeadline) {
756
+ if (lockWait.expired()) {
668
757
  throw new WorkspaceDirectoryError('E_WORKSPACE_DIR_LOCKED', `another workspace migration still owns: ${lockFile}`);
669
758
  }
670
- _sleepSync(Math.min(50, Math.max(1, lockDeadline - Date.now())));
759
+ _sleepSync(Math.min(50, Math.max(1, lockWait.remainingMs())));
671
760
  }
672
761
  }
673
- if (report.waitedForLock) report.lockWaitedMs = Math.max(0, Date.now() - lockStartedAt);
762
+ if (!migrationLockOwned) _startMigrationHeartbeat(lockFd);
763
+ if (report.waitedForLock) report.lockWaitedMs = Math.max(0, _monotonicMs() - lockStartedAt);
674
764
  // The previous owner can finish between our last EEXIST observation and
675
765
  // this successful open. Re-inspect under ownership before applying the
676
766
  // pre-lock source/backup snapshot.
@@ -693,6 +783,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
693
783
  fs.mkdirSync(migrationRoot, { recursive: true });
694
784
 
695
785
  for (const item of mapped) {
786
+ _beat();
696
787
  const dst = path.join(state.canonical.abs, item.mappedRel);
697
788
  const currentSnapshot = _destinationSnapshot(dst);
698
789
  if (!_sameDestinationSnapshot(item.destinationSnapshot, currentSnapshot)) {
@@ -713,14 +804,15 @@ function _migrateLegacyWorkspace(root, opts = {}) {
713
804
 
714
805
  // A writer that ignored the migration lock must not be silently discarded.
715
806
  const after = _walkFiles(state.legacy.abs);
716
- const beforeSig = sourceFiles.map((x) => `${x.rel}\0${x.hash}`).join('\n');
717
- const afterSig = after.map((x) => `${x.rel}\0${x.hash}`).join('\n');
807
+ const beforeSig = sourceFiles.map((x) => { _beat(); return `${x.rel}\0${x.hash}`; }).join('\n');
808
+ const afterSig = after.map((x) => { _beat(); return `${x.rel}\0${x.hash}`; }).join('\n');
718
809
  if (beforeSig !== afterSig) throw new WorkspaceDirectoryError('E_WORKSPACE_DIR_CHANGED', `${LEGACY_WORKSPACE_DIR} changed during migration`);
719
810
 
720
811
  // Rewrite only files that are part of the migrated workspace plus a small,
721
812
  // explicit set of leerness integration files at the project root. The
722
813
  // source backup remains byte-for-byte legacy data for recovery.
723
814
  for (const item of mapped) {
815
+ _beat();
724
816
  const dst = path.join(state.canonical.abs, item.mappedRel);
725
817
  if (_rewriteLegacyReference(dst)) report.rewrittenReferences.push(path.relative(state.root, dst).replace(/\\/g, '/'));
726
818
  }
@@ -822,8 +914,9 @@ function _migrateLegacyWorkspace(root, opts = {}) {
822
914
  fs.renameSync(sourceBackup, state.legacy.abs);
823
915
  legacyRenamed = false;
824
916
  }
825
- for (const item of overwritten.reverse()) _copyFile(item.backup, item.dst);
826
- for (const file of created.reverse()) if (fs.existsSync(file)) fs.rmSync(file, { force: true });
917
+ for (const item of overwritten.reverse()) { _beat(); _copyFile(item.backup, item.dst); }
918
+ for (const file of created.reverse()) { _beat(); if (fs.existsSync(file)) fs.rmSync(file, { force: true }); }
919
+ _beat();
827
920
  if (!canonicalExisted && fs.existsSync(state.canonical.abs)) _safeRemoveCanonicalDir(state.root, state.canonical.abs);
828
921
  } catch (rollbackError) {
829
922
  report.errors.push(`rollback failed: ${rollbackError.message}`);
@@ -831,6 +924,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
831
924
  return report;
832
925
  } finally {
833
926
  if (!migrationLockOwned && lockFd !== null) {
927
+ _stopMigrationHeartbeat();
834
928
  try { fs.closeSync(lockFd); } catch {}
835
929
  _removeOwnedLockFile(lockFile);
836
930
  }
@@ -838,6 +932,11 @@ function _migrateLegacyWorkspace(root, opts = {}) {
838
932
  }
839
933
 
840
934
  module.exports = {
935
+ // T-0348: the progress-based wait for the migration lock, shared with lib/runtime-layout.js.
936
+ migrationLockWait,
937
+ MIGRATION_LOCK_MAX_WAIT_MS,
938
+ MIGRATION_LOCK_MIN_WAIT_MS,
939
+ _copiedFiles,
841
940
  CANONICAL_WORKSPACE_DIR,
842
941
  LEGACY_WORKSPACE_DIR,
843
942
  LEGACY_BACKUP_WORKSPACE_DIR,