leerness 1.36.299 → 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.
@@ -0,0 +1,334 @@
1
+ 'use strict';
2
+ // T-0348: the workspace migration lock wait counts time without progress, not time in all. The migrating process
3
+ // refreshes the lock file's modification time while it works, and a waiting command keeps waiting while that time
4
+ // moves; a lock that does not move still fails after LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS. Each case uses a
5
+ // short wait (1-1.5 s) and a migration or a refreshed lock that lasts longer than it.
6
+ const assert = require('assert');
7
+ const cp = require('child_process');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
13
+ const ENV = { ...process.env, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_LOCK_WARN: '1', LEERNESS_NO_AUTOCHCP: '1' };
14
+ const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-migration-heartbeat-'));
15
+ let passed = 0, failed = 0, seq = 0;
16
+ // HEARTBEAT_PROBE_ONLY=<text> runs only the cases whose name contains it (to repeat one timing case).
17
+ async function test(name, fn) {
18
+ if (process.env.HEARTBEAT_PROBE_ONLY && !name.includes(process.env.HEARTBEAT_PROBE_ONLY)) return;
19
+ try { await fn(); passed++; console.log('PASS ' + name); }
20
+ catch (error) { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); }
21
+ }
22
+ const nap = ms => new Promise(resolve => setTimeout(resolve, ms));
23
+ const lockOf = d => path.join(d, '.leerness-workspace-migration.lock');
24
+ // A child leerness command; resolves to { status, ms, json } when it exits.
25
+ function start(args, env = {}, preload = null) {
26
+ const t0 = Date.now();
27
+ const child = cp.spawn(process.execPath, [...(preload ? ['--require', preload] : []), CLI, ...args],
28
+ { env: { ...ENV, ...env }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
29
+ let out = '', err = '';
30
+ child.stdout.on('data', b => { out += b; });
31
+ child.stderr.on('data', b => { err += b; });
32
+ return new Promise(resolve => child.on('close', status => {
33
+ let json = null; try { json = JSON.parse(out); } catch {}
34
+ resolve({ status, ms: Date.now() - t0, json, out, err });
35
+ }));
36
+ }
37
+ const LEGACY_DIR = '.harness'; // workspace-dir-legacy-fixture: the legacy workspace these migrations move
38
+ // `referencing`: each file names a path in the legacy workspace, so the migration rewrites it after the copy.
39
+ function legacy(files, referencing = false) {
40
+ const d = path.join(arena, 'legacy-' + (++seq));
41
+ fs.mkdirSync(path.join(d, LEGACY_DIR, 'bulk'), { recursive: true });
42
+ fs.writeFileSync(path.join(d, LEGACY_DIR, 'HARNESS_VERSION'), '1.36.161\n');
43
+ fs.writeFileSync(path.join(d, LEGACY_DIR, 'progress-tracker.md'), '# heartbeat probe\n');
44
+ for (let i = 0; i < files; i++) {
45
+ fs.writeFileSync(path.join(d, LEGACY_DIR, 'bulk', `f-${String(i).padStart(4, '0')}.md`), referencing ? `${i}: see ${LEGACY_DIR}/bulk\n` : `${i}\n`);
46
+ }
47
+ return d;
48
+ }
49
+ function canonical() {
50
+ const d = path.join(arena, 'canonical-' + (++seq));
51
+ fs.mkdirSync(d);
52
+ const r = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--minimal'], { env: ENV, encoding: 'utf8', timeout: 120000, windowsHide: true });
53
+ assert.strictEqual(r.status, 0, 'init: ' + r.stdout + r.stderr);
54
+ return d;
55
+ }
56
+ // A lock file that another (absent) process holds; refreshed every 300 ms while `refreshMs` lasts, then left.
57
+ async function heldLock(d, refreshMs, removeAfter) {
58
+ const lock = lockOf(d);
59
+ fs.writeFileSync(lock, 'held by the probe\n');
60
+ const until = Date.now() + refreshMs;
61
+ const timer = setInterval(() => { if (Date.now() < until) { const t = new Date(); try { fs.utimesSync(lock, t, t); } catch {} } }, 300);
62
+ if (removeAfter !== undefined) setTimeout(() => { clearInterval(timer); try { fs.unlinkSync(lock); } catch {} }, removeAfter);
63
+ return () => { clearInterval(timer); try { fs.unlinkSync(lock); } catch {} };
64
+ }
65
+ // Makes every copy of the migration slow (40 ms), so a migration of 120 files lasts several seconds.
66
+ const SLOW = path.join(arena, 'slow-copy.js');
67
+ fs.writeFileSync(SLOW, "const fs = require('fs'); const copy = fs.copyFileSync;\n"
68
+ + "fs.copyFileSync = function (...a) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 40); return copy.apply(this, a); };\n");
69
+ // Makes every rewrite of a migrated file slow (40 ms) and leaves the copies fast: only the reference rewrite after the
70
+ // copy lasts long (review round 1: that phase refreshed nothing).
71
+ const SLOW_REWRITE = path.join(arena, 'slow-rewrite.js');
72
+ fs.writeFileSync(SLOW_REWRITE, "const fs = require('fs'); const path = require('path'); const write = fs.writeFileSync;\n"
73
+ + "const inBulk = path.sep + '.leerness' + path.sep + 'bulk' + path.sep;\n"
74
+ + "fs.writeFileSync = function (file, ...a) { if (typeof file === 'string' && file.includes(inBulk)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 40); return write.call(this, file, ...a); };\n");
75
+ // Makes every existence check of a destination slow (40 ms): the check before the copy, where every destination is
76
+ // still missing, lasts long (review round 2: a missing destination refreshed nothing there).
77
+ const SLOW_CHECK = path.join(arena, 'slow-check.js');
78
+ fs.writeFileSync(SLOW_CHECK, "const fs = require('fs'); const path = require('path'); const exists = fs.existsSync;\n"
79
+ + "const inBulk = path.sep + '.leerness' + path.sep + 'bulk' + path.sep;\n"
80
+ + "fs.existsSync = function (file) { if (typeof file === 'string' && file.includes(inBulk)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 40); return exists.call(this, file); };\n");
81
+
82
+ // Slows every synchronous fs call on a path inside `project` by 30 ms, except lstat and stat (the runtime layout
83
+ // refuses a wrapped one even with no delay) and the lock file. Any phase of the migration that refreshes nothing
84
+ // then shows as a long time with an unchanged lock (review round 2 found such a loop; this checks all of them).
85
+ function slowAll(project) {
86
+ const file = path.join(arena, `slow-all-${++seq}.js`);
87
+ const names = ['existsSync', 'readFileSync', 'writeFileSync', 'copyFileSync', 'readdirSync', 'mkdirSync', 'renameSync', 'rmSync', 'unlinkSync', 'openSync', 'appendFileSync'];
88
+ fs.writeFileSync(file, `const fs = require('fs'); const P = ${JSON.stringify(project)};\n`
89
+ + `for (const name of ${JSON.stringify(names)}) { const orig = fs[name]; fs[name] = function (f, ...a) {\n`
90
+ + ` if (typeof f === 'string' && f.startsWith(P) && !f.endsWith('.lock')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30);\n`
91
+ + ' return orig.call(this, f, ...a); }; }\n');
92
+ return file;
93
+ }
94
+
95
+ (async () => {
96
+ try {
97
+ await test('every loop of the migration refreshes the lock, unless it waits for another owner or is a fixed list (source)', async () => {
98
+ // Review round 6: the mapping and collision loops are CPU work only, so slowing file calls (the sweep case
99
+ // below) cannot show them; 120,000 long paths took 3.2 s there. This reads the migration's functions and
100
+ // requires `_beat()` on each loop's line or the next one, apart from the allowances listed below.
101
+ const src = fs.readFileSync(path.join(__dirname, '..', 'lib', 'workspace-dir.js'), 'utf8').split(/\r?\n/);
102
+ // The functions checked are all those the migration reaches by calls within the module (review round 8: a
103
+ // hand-picked list left out the workspace inspection that runs after the lock is taken).
104
+ const bodies = new Map();
105
+ src.forEach((l, i) => {
106
+ const m = /^function (\w+)\(/.exec(l);
107
+ if (!m) return;
108
+ let end = src.findIndex((x, j) => j > i && /^(function |const |let |class |module\.exports)/.test(x));
109
+ bodies.set(m[1], [i, end < 0 ? src.length : end]);
110
+ });
111
+ const reach = new Set(), queue = ['migrateLegacyWorkspace'];
112
+ while (queue.length) {
113
+ const fn = queue.shift();
114
+ if (reach.has(fn)) continue;
115
+ assert.ok(bodies.has(fn), 'function not found: ' + fn);
116
+ reach.add(fn);
117
+ const [s, e] = bodies.get(fn), text = src.slice(s + 1, e).join('\n');
118
+ for (const other of bodies.keys()) if (new RegExp(`(^|[^\\w$.])${other}\\(`).test(text)) queue.push(other);
119
+ }
120
+ for (const fn of ['_migrateLegacyWorkspace', '_inspectDir', '_walkFiles', '_copiedFiles', '_hashFile', '_destinationSnapshot']) {
121
+ assert.ok(reach.has(fn), 'call graph lost ' + fn);
122
+ }
123
+ // Sorting counts as a loop (review round 7: sorting 1,200,000 names took 1.95 s before the first visit).
124
+ const LOOP = /\bfor\s*\(|\bwhile\s*\(|\.(?:map|some|filter|forEach|every|reduce|find|findIndex|flatMap|sort)\(/;
125
+ // Each allowance names its function and the exact loop (a looser `visit(` allowance once also passed the line
126
+ // that sorted before visiting): the waits for another process's lock, the bounded release retry, the fixed
127
+ // list of root integration files, the three protected .gitignore lines and one .gitignore's own lines, the
128
+ // parts of one path, and the walk's per-entry visit, which refreshes itself.
129
+ const ALLOWED = [
130
+ ['migrateLegacyWorkspace', /while \(lockFd === null\)/],
131
+ ['_migrateLegacyWorkspace', /while \(fs\.existsSync\(lockFile\)\)/],
132
+ ['_migrateLegacyWorkspace', /while \(lockFd === null\)/],
133
+ ['_removeOwnedLockFile', /while \(true\)/],
134
+ ['_migrateLegacyWorkspace', /of integrationFiles\)/],
135
+ ['_migrateLegacyWorkspace', /PROTECTED_GITIGNORE_LINES\.some\(/],
136
+ ['_mergeProtectedGitignore', /raw\.toString\('latin1'\)\.split\(/],
137
+ ['_mergeProtectedGitignore', /PROTECTED_GITIGNORE_LINES\.filter\(/],
138
+ ['_assertNoLinkedPath', /for \(const part of rel\.split\(path\.sep\)\)/],
139
+ ['_walkFiles', /^\s*for \(const entry of entries\) visit\(/],
140
+ ];
141
+ const SORT_REFRESHES = /\.sort\(\(\w+, \w+\) => \{ _beat\(\);/;
142
+ const missing = [];
143
+ let loops = 0;
144
+ for (const fn of reach) {
145
+ const [start, end] = bodies.get(fn);
146
+ for (let i = start; i < end; i++) {
147
+ if (!LOOP.test(src[i]) || /^\s*\/\//.test(src[i])) continue;
148
+ loops++;
149
+ if (/\.sort\(/.test(src[i]) && !SORT_REFRESHES.test(src[i])) { missing.push(`${fn}:${i + 1} sort without a refreshing comparator`); continue; }
150
+ if (/_beat\(\)/.test(src[i]) || /^\s*_beat\(\);/.test(src[i + 1] || '')) continue;
151
+ if (ALLOWED.some(([f, re]) => f === fn && re.test(src[i]))) continue;
152
+ missing.push(`${fn}:${i + 1} ${src[i].trim().slice(0, 80)}`);
153
+ }
154
+ }
155
+ assert.ok(loops >= 25, 'too few loops found (' + loops + '): the function boundaries moved');
156
+ assert.deepStrictEqual(missing, []);
157
+ });
158
+ await test('the list of files to copy stays linear in the file count (60,000 files, 30,000 already there)', async () => {
159
+ // Review round 3: the list searched the skipped files once per file; 60,000 took 33 s in the reviewer's
160
+ // reproduction, past a waiter's 10 s. The Set version takes milliseconds; 2 s leaves room for a slow runner.
161
+ const { _copiedFiles } = require('../lib/workspace-dir');
162
+ const mapped = Array.from({ length: 60000 }, (_, i) => ({ mappedRel: path.join('cache', 'agent-runs', `r-${i}.json`) }));
163
+ const skipped = mapped.filter((_, i) => i % 2 === 0).map(m => m.mappedRel.replace(/\\/g, '/'));
164
+ const t0 = Date.now();
165
+ const out = _copiedFiles(mapped, skipped);
166
+ const ms = Date.now() - t0;
167
+ assert.strictEqual(out.length, 30000);
168
+ assert.strictEqual(out[0], 'cache/agent-runs/r-1.json');
169
+ assert.strictEqual(out[29999], 'cache/agent-runs/r-59999.json');
170
+ assert.ok(ms < 2000, 'took ' + ms + ' ms');
171
+ });
172
+ await test('no phase of a migration leaves its lock unchanged for long (every fs call slowed)', async () => {
173
+ const d = legacy(60, true);
174
+ fs.writeFileSync(path.join(d, 'AGENTS.md'), `See ${LEGACY_DIR}/progress-tracker.md\n`);
175
+ const owner = start(['migrate-workspace-dir', d, '--json'], {}, slowAll(d));
176
+ let done = false; owner.then(() => { done = true; });
177
+ let last = 'absent', lastAt = Date.now(), maxGap = 0, changes = 0;
178
+ while (!done) {
179
+ let sig = 'absent';
180
+ try { sig = String(fs.statSync(lockOf(d), { bigint: true }).mtimeNs); } catch {}
181
+ const now = Date.now();
182
+ if (sig !== last) {
183
+ if (last !== 'absent') maxGap = Math.max(maxGap, now - lastAt);
184
+ if (sig !== 'absent') changes++;
185
+ last = sig; lastAt = now;
186
+ }
187
+ await nap(10);
188
+ }
189
+ const r = await owner;
190
+ assert.strictEqual(r.status, 0, r.out + r.err);
191
+ assert.strictEqual(r.json && r.json.migrated, true, r.out);
192
+ assert.ok(changes >= 10, 'the lock was refreshed while the migration ran: ' + changes + ' changes in ' + r.ms + ' ms');
193
+ assert.ok(maxGap < 1500, 'longest time with an unchanged lock: ' + maxGap + ' ms (refresh every 500 ms)');
194
+ });
195
+ await test('a migration that outlasts the wait does not fail the migration waiting for it', async () => {
196
+ const d = legacy(120);
197
+ const owner = start(['migrate-workspace-dir', d, '--json'], {}, SLOW);
198
+ for (let i = 0; i < 400 && !fs.existsSync(lockOf(d)); i++) await nap(10);
199
+ assert.ok(fs.existsSync(lockOf(d)), 'the owner took the lock');
200
+ const waiter = await start(['migrate-workspace-dir', d, '--json'], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '1500' });
201
+ const first = await owner;
202
+ assert.strictEqual(first.status, 0, first.out + first.err);
203
+ assert.strictEqual(first.json && first.json.migrated, true, first.out);
204
+ assert.strictEqual(waiter.status, 0, waiter.out + waiter.err);
205
+ assert.ok(waiter.json && waiter.json.alreadyCanonical === true && waiter.json.waitedForLock === true, waiter.out);
206
+ assert.ok(waiter.json.lockWaitedMs > 1500, 'the waiter waited longer than its wait: ' + waiter.json.lockWaitedMs);
207
+ });
208
+ await test('a migration whose reference rewrite outlasts the wait does not fail the migration waiting for it', async () => {
209
+ const d = legacy(80, true);
210
+ const owner = start(['migrate-workspace-dir', d, '--json'], {}, SLOW_REWRITE);
211
+ for (let i = 0; i < 400 && !fs.existsSync(lockOf(d)); i++) await nap(10);
212
+ assert.ok(fs.existsSync(lockOf(d)), 'the owner took the lock');
213
+ const waiter = await start(['migrate-workspace-dir', d, '--json'], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '1500' });
214
+ const first = await owner;
215
+ assert.strictEqual(first.status, 0, first.out + first.err);
216
+ assert.strictEqual(first.json && first.json.migrated, true, first.out);
217
+ assert.ok(first.json.rewrittenReferences.length >= 80, 'the owner rewrote the references: ' + first.json.rewrittenReferences.length);
218
+ assert.strictEqual(waiter.status, 0, waiter.out + waiter.err);
219
+ assert.ok(waiter.json && waiter.json.alreadyCanonical === true && waiter.json.waitedForLock === true, waiter.out);
220
+ assert.ok(waiter.json.lockWaitedMs > 1500, 'the waiter waited longer than its wait: ' + waiter.json.lockWaitedMs);
221
+ });
222
+ await test('a migration whose destination check outlasts the wait does not fail the migration waiting for it', async () => {
223
+ const d = legacy(80);
224
+ const owner = start(['migrate-workspace-dir', d, '--json'], {}, SLOW_CHECK);
225
+ for (let i = 0; i < 400 && !fs.existsSync(lockOf(d)); i++) await nap(10);
226
+ assert.ok(fs.existsSync(lockOf(d)), 'the owner took the lock');
227
+ const waiter = await start(['migrate-workspace-dir', d, '--json'], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '1500' });
228
+ const first = await owner;
229
+ assert.strictEqual(first.status, 0, first.out + first.err);
230
+ assert.strictEqual(first.json && first.json.migrated, true, first.out);
231
+ assert.strictEqual(waiter.status, 0, waiter.out + waiter.err);
232
+ assert.ok(waiter.json && waiter.json.alreadyCanonical === true && waiter.json.waitedForLock === true, waiter.out);
233
+ assert.ok(waiter.json.lockWaitedMs > 1500, 'the waiter waited longer than its wait: ' + waiter.json.lockWaitedMs);
234
+ });
235
+ await test('a wait shorter than the refresh interval still outlasts a progressing migration (100 ms set)', async () => {
236
+ // Review round 4: the owner refreshes at most every 500 ms; a 100 ms wait expired between two refreshes.
237
+ const d = legacy(120);
238
+ const owner = start(['migrate-workspace-dir', d, '--json'], {}, SLOW);
239
+ for (let i = 0; i < 400 && !fs.existsSync(lockOf(d)); i++) await nap(10);
240
+ assert.ok(fs.existsSync(lockOf(d)), 'the owner took the lock');
241
+ const waiter = await start(['migrate-workspace-dir', d, '--json'], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '100' });
242
+ const first = await owner;
243
+ assert.strictEqual(first.status, 0, first.out + first.err);
244
+ assert.strictEqual(waiter.status, 0, waiter.out + waiter.err);
245
+ assert.ok(waiter.json && waiter.json.alreadyCanonical === true && waiter.json.waitedForLock === true, waiter.out);
246
+ });
247
+ await test('a positive wait is at least 1.5 s; 0 does not wait (fake clock)', async () => {
248
+ const { migrationLockWait, MIGRATION_LOCK_MIN_WAIT_MS } = require('../lib/workspace-dir');
249
+ assert.strictEqual(MIGRATION_LOCK_MIN_WAIT_MS, 1500);
250
+ const lock = path.join(arena, 'clock.lock');
251
+ fs.writeFileSync(lock, 'x');
252
+ let now = 1000;
253
+ const w = migrationLockWait(lock, 100, 60000, () => now);
254
+ assert.strictEqual(w.expired(), false);
255
+ now += 1499; assert.strictEqual(w.expired(), false, 'expired before the minimum wait');
256
+ assert.strictEqual(w.remainingMs(), 1);
257
+ now += 1; assert.strictEqual(w.expired(), true, 'not expired after the minimum wait');
258
+ const zero = migrationLockWait(lock, 0, 60000, () => now);
259
+ assert.strictEqual(zero.expired(), true, 'a 0 wait must not wait');
260
+ const long = migrationLockWait(lock, 5000, 60000, () => now);
261
+ assert.strictEqual(long.expired(), false);
262
+ now += 4999; assert.strictEqual(long.expired(), false);
263
+ now += 1; assert.strictEqual(long.expired(), true);
264
+ });
265
+ await test('the owner keeps refreshing when its wall clock is set back 30 s mid-migration', async () => {
266
+ // Review round 5: the refresh interval was measured with Date.now(); a clock set back stopped the refreshes
267
+ // until it caught up, and the waiter gave up while the migration went on.
268
+ // The clock goes back after the tenth copy, so the refreshes have started (a jump before the first refresh
269
+ // leaves a wall-clock throttle consistent and proves nothing).
270
+ const back = path.join(arena, 'clock-back.js');
271
+ fs.writeFileSync(back, `require(${JSON.stringify(SLOW)});\n`
272
+ + "const fs = require('fs'); const copy = fs.copyFileSync; let copies = 0, offset = 0;\n"
273
+ + 'fs.copyFileSync = function (...a) { if (++copies === 10) offset = -30000; return copy.apply(this, a); };\n'
274
+ + 'const real = Date.now; Date.now = () => real() + offset;\n');
275
+ const d = legacy(120);
276
+ const owner = start(['migrate-workspace-dir', d, '--json'], {}, back);
277
+ for (let i = 0; i < 400 && !fs.existsSync(lockOf(d)); i++) await nap(10);
278
+ assert.ok(fs.existsSync(lockOf(d)), 'the owner took the lock');
279
+ const waiter = await start(['migrate-workspace-dir', d, '--json'], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '1500' });
280
+ const first = await owner;
281
+ assert.strictEqual(first.status, 0, first.out + first.err);
282
+ assert.strictEqual(waiter.status, 0, waiter.out + waiter.err);
283
+ assert.ok(waiter.json && waiter.json.alreadyCanonical === true && waiter.json.waitedForLock === true, waiter.out);
284
+ });
285
+ await test('a wait is not cut short when the wall clock jumps forward', async () => {
286
+ const { migrationLockWait } = require('../lib/workspace-dir');
287
+ const lock = path.join(arena, 'jump.lock');
288
+ fs.writeFileSync(lock, 'x');
289
+ // A wall clock that jumps: Date.now returns real time plus an offset that the test moves, as a system clock
290
+ // change would (installed before the wait is made, so a wait that reads Date.now sees the jump).
291
+ const real = Date.now;
292
+ let offset = 0;
293
+ try {
294
+ Date.now = () => real() + offset;
295
+ const w = migrationLockWait(lock, 5000);
296
+ assert.strictEqual(w.expired(), false);
297
+ offset = 60000;
298
+ assert.strictEqual(w.expired(), false, 'a 60 s forward jump ended a 5 s wait at once');
299
+ assert.ok(w.remainingMs() > 4000, 'remaining ' + w.remainingMs());
300
+ } finally { Date.now = real; }
301
+ });
302
+ await test('a writer waits while the lock keeps changing and goes on when it is released', async () => {
303
+ const d = canonical();
304
+ await heldLock(d, 3500, 3500);
305
+ const r = await start(['task', 'add', 'heartbeat probe task', '--no-review', '--path', d], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '1000' });
306
+ assert.strictEqual(r.status, 0, r.out + r.err);
307
+ assert.ok(r.ms >= 3000, 'the writer waited for the release: ' + r.ms + ' ms');
308
+ assert.ok(fs.readFileSync(path.join(d, '.leerness', 'progress-tracker.md'), 'utf8').includes('heartbeat probe task'));
309
+ });
310
+ await test('a lock that never changes still fails after the wait', async () => {
311
+ const d = canonical();
312
+ const release = await heldLock(d, 0);
313
+ try {
314
+ const r = await start(['task', 'add', 'stale lock task', '--no-review', '--path', d], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '1000' });
315
+ assert.notStrictEqual(r.status, 0, 'a stale lock must stop the writer');
316
+ assert.ok(/workspace_dir_migration_locked|workspace-dir-migration-locked/.test(r.out + r.err), r.out + r.err);
317
+ assert.ok(r.ms < 8000, 'it failed after the wait, not after the cap: ' + r.ms + ' ms');
318
+ } finally { release(); }
319
+ });
320
+ await test('a lock that stops changing fails a wait after its last change', async () => {
321
+ const d = canonical();
322
+ const release = await heldLock(d, 4000);
323
+ try {
324
+ const r = await start(['task', 'add', 'stopped lock task', '--no-review', '--path', d], { LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS: '1000' });
325
+ assert.notStrictEqual(r.status, 0, 'the writer must stop once the lock stops changing');
326
+ assert.ok(r.ms >= 4000 && r.ms < 12000, 'it waited while the lock changed and then about one wait: ' + r.ms + ' ms');
327
+ } finally { release(); }
328
+ });
329
+ } finally {
330
+ fs.rmSync(arena, { recursive: true, force: true });
331
+ }
332
+ console.log(`workspace migration heartbeat probe: ${passed}/${passed + failed} passed (${process.platform})`);
333
+ process.exitCode = failed ? 1 : 0;
334
+ })();