novahiz 0.9.0 → 0.9.2
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 +43 -0
- package/README.md +1 -1
- package/dist/catalog.js +7 -1
- package/dist/memory.js +63 -25
- package/package.json +1 -1
- package/src/catalog.ts +6 -1
- package/src/memory.ts +59 -25
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,49 @@
|
|
|
3
3
|
All notable changes to Novahiz are documented in this file.
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [0.9.2] - 2026-10-11
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **Memory root lock: no more silent marker loss under contention** — a
|
|
11
|
+
transient `EPERM`/`EBUSY` when reading `.lock` (Windows filter driver
|
|
12
|
+
under I/O load) was indistinguishable from "absent", so a waiter could
|
|
13
|
+
delete the lock of a **live** holder: two critical sections, one
|
|
14
|
+
read-modify-write clobbered, one marker lost while both writers exited 0.
|
|
15
|
+
`readLock` is now tri-state (`ok` / `absent` / `unreadable`); a steal
|
|
16
|
+
requires a confirmed stale lock (dead pid or age) plus an immediate
|
|
17
|
+
re-read (closes the TOCTOU window); a torn file left by a crash is
|
|
18
|
+
detected via `mtime > LOCK_STALE_MS` and only then removed.
|
|
19
|
+
- **`acquireRootLock` no longer crashes on transient create errors** — the
|
|
20
|
+
same `EPERM` on the `wx` create (reproduced: 2 crashes in 40 rounds
|
|
21
|
+
under 6 CPU burners) now retries with the existing deadline and ends in
|
|
22
|
+
a loud `E_LOCK` instead of killing the caller.
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
|
|
26
|
+
- Proven under load: 60/60 stress rounds clean after the fix (2/40 rounds
|
|
27
|
+
crashed before). Two new deterministic tests (torn + fresh mtime →
|
|
28
|
+
`E_LOCK` with the foreign lock intact; torn + old mtime → stolen).
|
|
29
|
+
Suite 613 tests / 612 pass / 0 fail; CI green on Windows and Linux.
|
|
30
|
+
|
|
31
|
+
## [0.9.1] - 2026-10-10
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Fresh-install scan stops reporting missing skill roots** — the default
|
|
36
|
+
`skillRoots` include `~/.agents/skills`, which most profiles do not have.
|
|
37
|
+
The first `sync` (run by the installer) used to surface it as a scan error
|
|
38
|
+
on every new machine; a declared root that does not exist yet now simply
|
|
39
|
+
contributes nothing (`scanErrors: []`).
|
|
40
|
+
- **README install banner aligned with the shipped version** (0.8.0 → 0.9.0).
|
|
41
|
+
|
|
42
|
+
### Notes
|
|
43
|
+
|
|
44
|
+
- The npm installer path is verified end-to-end on npm 12.2: install with the
|
|
45
|
+
`postinstall` blocked by `allowScripts`, first `novahiz` run finishes the
|
|
46
|
+
setup itself (auto-install, `NOVAHIZ_AUTOINSTALL=0` to opt out), second run
|
|
47
|
+
clean. `novahiz doctor` exits 0 on the fresh sandbox.
|
|
48
|
+
|
|
6
49
|
## [0.9.0] - 2026-10-10
|
|
7
50
|
|
|
8
51
|
### Added
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
17 catégories, 94 skills, 13 gate rules, 8 MCP providers — tout déterministe, tout local, tout JSON.
|
|
6
6
|
|
|
7
|
-
> **Version 0.
|
|
7
|
+
> **Version 0.9.0** — publiée sur npm (`npm install -g --allow-scripts=novahiz novahiz` installe et configure tout : core, plugin, agent, skills packs et Playwright MCP). `CHANGELOG.md` et `scripts/` sont dans le tarball.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
┌──────────────────────────────────────────────────────────────────────────┐
|
package/dist/catalog.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, isAbsolute, join } from "node:path";
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { expandHome } from "./spec.js";
|
|
@@ -56,6 +56,12 @@ export function parseFrontmatter(content) {
|
|
|
56
56
|
return result;
|
|
57
57
|
}
|
|
58
58
|
function walkForSkillFiles(root, found, visited, errors) {
|
|
59
|
+
// A declared skill root that does not exist yet is normal on a fresh machine:
|
|
60
|
+
// the default skillRoots include ~/.agents/skills, which most profiles do not
|
|
61
|
+
// have. It contributes nothing and must not surface as a scan error in every
|
|
62
|
+
// first sync (the installer prints that JSON).
|
|
63
|
+
if (!existsSync(root))
|
|
64
|
+
return;
|
|
59
65
|
let real;
|
|
60
66
|
try {
|
|
61
67
|
real = realpathSync(root);
|
package/dist/memory.js
CHANGED
|
@@ -203,25 +203,57 @@ function isProcessAlive(pid) {
|
|
|
203
203
|
return error.code === "EPERM";
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
|
-
function
|
|
206
|
+
function readLock(root) {
|
|
207
|
+
let raw;
|
|
207
208
|
try {
|
|
208
|
-
|
|
209
|
-
const [pidRaw, atRaw] = raw.split("\n");
|
|
210
|
-
const pid = Number(pidRaw);
|
|
211
|
-
const at = Number(atRaw);
|
|
212
|
-
if (!Number.isInteger(pid) || !Number.isFinite(at))
|
|
213
|
-
return null;
|
|
214
|
-
return { pid, at };
|
|
209
|
+
raw = readFileSync(lockPath(root), "utf8");
|
|
215
210
|
}
|
|
216
|
-
catch {
|
|
217
|
-
//
|
|
218
|
-
|
|
211
|
+
catch (error) {
|
|
212
|
+
// ENOENT = certitude: pas de verrou. Tout le reste (EPERM/EBUSY/EACCES:
|
|
213
|
+
// filtre antivirus sous tempete I/O sur Windows, reproduit en stress)
|
|
214
|
+
// est incertain — une lecture qui echoue ne prouve pas que le detenteur
|
|
215
|
+
// est mort, et traiter l'incertain comme un verrou perime volait le
|
|
216
|
+
// verrou d'un processus vivant: deux sections critiques, une ecriture
|
|
217
|
+
// ecrasee, un marqueur perdu sans erreur.
|
|
218
|
+
const code = error.code;
|
|
219
|
+
return code === "ENOENT" ? { state: "absent" } : { state: "unreadable" };
|
|
220
|
+
}
|
|
221
|
+
const [pidRaw, atRaw] = raw.split("\n");
|
|
222
|
+
const pid = Number(pidRaw);
|
|
223
|
+
const at = Number(atRaw);
|
|
224
|
+
if (!Number.isInteger(pid) || !Number.isFinite(at))
|
|
225
|
+
return { state: "unreadable" };
|
|
226
|
+
return { state: "ok", info: { pid, at } };
|
|
227
|
+
}
|
|
228
|
+
function tryStealLock(root) {
|
|
229
|
+
const read = readLock(root);
|
|
230
|
+
if (read.state === "absent")
|
|
231
|
+
return true;
|
|
232
|
+
if (read.state === "unreadable") {
|
|
233
|
+
// Lecture incertaine ou contenu dechire: jamais de vol sur une lecture
|
|
234
|
+
// qui a echoue. Un fichier dechire abandonne par un crash (write non
|
|
235
|
+
// atomique) ne se detecte que par son age: mtime au-dela de
|
|
236
|
+
// LOCK_STALE_MS = detenteur disparu, on peut le retirer.
|
|
237
|
+
try {
|
|
238
|
+
if (Date.now() - statSync(lockPath(root)).mtimeMs <= LOCK_STALE_MS)
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const { info } = read;
|
|
247
|
+
if (isProcessAlive(info.pid) && Date.now() - info.at <= LOCK_STALE_MS)
|
|
248
|
+
return false;
|
|
249
|
+
// Relecture avant unlink: si le verrou a change de main entre la
|
|
250
|
+
// decision de vol et la suppression (fenetre TOCTOU), on recule.
|
|
251
|
+
const again = readLock(root);
|
|
252
|
+
if (again.state === "unreadable")
|
|
253
|
+
return false;
|
|
254
|
+
if (again.state === "ok" && (again.info.pid !== info.pid || again.info.at !== info.at))
|
|
255
|
+
return false;
|
|
219
256
|
}
|
|
220
|
-
}
|
|
221
|
-
function tryStealLock(root, info) {
|
|
222
|
-
const stale = info === null || !isProcessAlive(info.pid) || Date.now() - info.at > LOCK_STALE_MS;
|
|
223
|
-
if (!stale)
|
|
224
|
-
return false;
|
|
225
257
|
try {
|
|
226
258
|
unlinkSync(lockPath(root));
|
|
227
259
|
}
|
|
@@ -252,20 +284,25 @@ export function acquireRootLock(root, waitMs = LOCK_WAIT_DEFAULT_MS) {
|
|
|
252
284
|
return () => releaseRootLock(root);
|
|
253
285
|
}
|
|
254
286
|
catch (error) {
|
|
255
|
-
|
|
287
|
+
const code = error.code;
|
|
288
|
+
// EEXIST = verrou pris; EPERM/EBUSY/EACCES = transitoire (filtre
|
|
289
|
+
// antivirus sous charge, EPERM reproduit en stress sur .lock): on
|
|
290
|
+
// reessaie dans la boucle au lieu de planter l'appelant. Le delai
|
|
291
|
+
// couvre un blocage durable (alors E_LOCK, bruyant, non corrupteur).
|
|
292
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "EBUSY" && code !== "EACCES")
|
|
256
293
|
throw error;
|
|
257
294
|
}
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
295
|
+
const read = readLock(root);
|
|
296
|
+
if (read.state === "ok" && read.info.pid === process.pid && Date.now() - read.info.at <= LOCK_STALE_MS) {
|
|
260
297
|
// Notre propre verrou (section interrompue par un crash precedent au
|
|
261
298
|
// meme pid): on le reprend en reentrant.
|
|
262
299
|
lockDepth.set(root, 1);
|
|
263
300
|
return () => releaseRootLock(root);
|
|
264
301
|
}
|
|
265
|
-
if (tryStealLock(root
|
|
302
|
+
if (tryStealLock(root))
|
|
266
303
|
continue;
|
|
267
304
|
if (Date.now() >= deadline) {
|
|
268
|
-
const holder =
|
|
305
|
+
const holder = read.state === "ok" ? String(read.info.pid) : read.state;
|
|
269
306
|
throw memError("E_LOCK", `verrou memoire occupe (pid ${holder}) sous ${root}`);
|
|
270
307
|
}
|
|
271
308
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -279,11 +316,12 @@ function releaseRootLock(root) {
|
|
|
279
316
|
}
|
|
280
317
|
lockDepth.delete(root);
|
|
281
318
|
try {
|
|
282
|
-
const
|
|
283
|
-
|
|
319
|
+
const read = readLock(root);
|
|
320
|
+
// Lecture incertaine ou absente: on est le detenteur, on libere quand
|
|
321
|
+
// meme. Un verrou lisible d'un autre pid n'est pas le notre (course
|
|
322
|
+
// apres peremption): on ne touche pas au fichier d'un tiers.
|
|
323
|
+
if (read.state !== "ok" || read.info.pid === process.pid)
|
|
284
324
|
unlinkSync(lockPath(root));
|
|
285
|
-
// Verrou repris par un autre (course apres peremption): on ne touche pas
|
|
286
|
-
// au fichier d'un tiers.
|
|
287
325
|
}
|
|
288
326
|
catch {
|
|
289
327
|
// Deja libere (ENOENT) ou lecture echouee: rien a faire.
|
package/package.json
CHANGED
package/src/catalog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, isAbsolute, join } from "node:path";
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
4
|
import { expandHome, type Spec } from "./spec.ts";
|
|
@@ -68,6 +68,11 @@ export function parseFrontmatter(content: string): Record<string, string> {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function walkForSkillFiles(root: string, found: string[], visited: Set<string>, errors: string[]): void {
|
|
71
|
+
// A declared skill root that does not exist yet is normal on a fresh machine:
|
|
72
|
+
// the default skillRoots include ~/.agents/skills, which most profiles do not
|
|
73
|
+
// have. It contributes nothing and must not surface as a scan error in every
|
|
74
|
+
// first sync (the installer prints that JSON).
|
|
75
|
+
if (!existsSync(root)) return;
|
|
71
76
|
let real: string;
|
|
72
77
|
try {
|
|
73
78
|
real = realpathSync(root);
|
package/src/memory.ts
CHANGED
|
@@ -295,24 +295,52 @@ function isProcessAlive(pid: number): boolean {
|
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
|
|
298
|
+
type LockInfo = { pid: number; at: number };
|
|
299
|
+
type LockRead = { state: "ok"; info: LockInfo } | { state: "absent" } | { state: "unreadable" };
|
|
300
|
+
|
|
301
|
+
function readLock(root: string): LockRead {
|
|
302
|
+
let raw: string;
|
|
299
303
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
|
|
304
|
+
raw = readFileSync(lockPath(root), "utf8");
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// ENOENT = certitude: pas de verrou. Tout le reste (EPERM/EBUSY/EACCES:
|
|
307
|
+
// filtre antivirus sous tempete I/O sur Windows, reproduit en stress)
|
|
308
|
+
// est incertain — une lecture qui echoue ne prouve pas que le detenteur
|
|
309
|
+
// est mort, et traiter l'incertain comme un verrou perime volait le
|
|
310
|
+
// verrou d'un processus vivant: deux sections critiques, une ecriture
|
|
311
|
+
// ecrasee, un marqueur perdu sans erreur.
|
|
312
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
313
|
+
return code === "ENOENT" ? { state: "absent" } : { state: "unreadable" };
|
|
314
|
+
}
|
|
315
|
+
const [pidRaw, atRaw] = raw.split("\n");
|
|
316
|
+
const pid = Number(pidRaw);
|
|
317
|
+
const at = Number(atRaw);
|
|
318
|
+
if (!Number.isInteger(pid) || !Number.isFinite(at)) return { state: "unreadable" };
|
|
319
|
+
return { state: "ok", info: { pid, at } };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function tryStealLock(root: string): boolean {
|
|
323
|
+
const read = readLock(root);
|
|
324
|
+
if (read.state === "absent") return true;
|
|
325
|
+
if (read.state === "unreadable") {
|
|
326
|
+
// Lecture incertaine ou contenu dechire: jamais de vol sur une lecture
|
|
327
|
+
// qui a echoue. Un fichier dechire abandonne par un crash (write non
|
|
328
|
+
// atomique) ne se detecte que par son age: mtime au-dela de
|
|
329
|
+
// LOCK_STALE_MS = detenteur disparu, on peut le retirer.
|
|
330
|
+
try {
|
|
331
|
+
if (Date.now() - statSync(lockPath(root)).mtimeMs <= LOCK_STALE_MS) return false;
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
const { info } = read;
|
|
337
|
+
if (isProcessAlive(info.pid) && Date.now() - info.at <= LOCK_STALE_MS) return false;
|
|
338
|
+
// Relecture avant unlink: si le verrou a change de main entre la
|
|
339
|
+
// decision de vol et la suppression (fenetre TOCTOU), on recule.
|
|
340
|
+
const again = readLock(root);
|
|
341
|
+
if (again.state === "unreadable") return false;
|
|
342
|
+
if (again.state === "ok" && (again.info.pid !== info.pid || again.info.at !== info.at)) return false;
|
|
309
343
|
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function tryStealLock(root: string, info: { pid: number; at: number } | null): boolean {
|
|
313
|
-
const stale =
|
|
314
|
-
info === null || !isProcessAlive(info.pid) || Date.now() - info.at > LOCK_STALE_MS;
|
|
315
|
-
if (!stale) return false;
|
|
316
344
|
try {
|
|
317
345
|
unlinkSync(lockPath(root));
|
|
318
346
|
} catch (error) {
|
|
@@ -342,18 +370,23 @@ export function acquireRootLock(root: string, waitMs = LOCK_WAIT_DEFAULT_MS): ()
|
|
|
342
370
|
lockDepth.set(root, 1);
|
|
343
371
|
return () => releaseRootLock(root);
|
|
344
372
|
} catch (error) {
|
|
345
|
-
|
|
373
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
374
|
+
// EEXIST = verrou pris; EPERM/EBUSY/EACCES = transitoire (filtre
|
|
375
|
+
// antivirus sous charge, EPERM reproduit en stress sur .lock): on
|
|
376
|
+
// reessaie dans la boucle au lieu de planter l'appelant. Le delai
|
|
377
|
+
// couvre un blocage durable (alors E_LOCK, bruyant, non corrupteur).
|
|
378
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw error;
|
|
346
379
|
}
|
|
347
|
-
const
|
|
348
|
-
if (
|
|
380
|
+
const read = readLock(root);
|
|
381
|
+
if (read.state === "ok" && read.info.pid === process.pid && Date.now() - read.info.at <= LOCK_STALE_MS) {
|
|
349
382
|
// Notre propre verrou (section interrompue par un crash precedent au
|
|
350
383
|
// meme pid): on le reprend en reentrant.
|
|
351
384
|
lockDepth.set(root, 1);
|
|
352
385
|
return () => releaseRootLock(root);
|
|
353
386
|
}
|
|
354
|
-
if (tryStealLock(root
|
|
387
|
+
if (tryStealLock(root)) continue;
|
|
355
388
|
if (Date.now() >= deadline) {
|
|
356
|
-
const holder =
|
|
389
|
+
const holder = read.state === "ok" ? String(read.info.pid) : read.state;
|
|
357
390
|
throw memError("E_LOCK", `verrou memoire occupe (pid ${holder}) sous ${root}`);
|
|
358
391
|
}
|
|
359
392
|
sleepSync(LOCK_RETRY_MS);
|
|
@@ -368,10 +401,11 @@ function releaseRootLock(root: string): void {
|
|
|
368
401
|
}
|
|
369
402
|
lockDepth.delete(root);
|
|
370
403
|
try {
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
//
|
|
374
|
-
// au fichier d'un tiers.
|
|
404
|
+
const read = readLock(root);
|
|
405
|
+
// Lecture incertaine ou absente: on est le detenteur, on libere quand
|
|
406
|
+
// meme. Un verrou lisible d'un autre pid n'est pas le notre (course
|
|
407
|
+
// apres peremption): on ne touche pas au fichier d'un tiers.
|
|
408
|
+
if (read.state !== "ok" || read.info.pid === process.pid) unlinkSync(lockPath(root));
|
|
375
409
|
} catch {
|
|
376
410
|
// Deja libere (ENOENT) ou lecture echouee: rien a faire.
|
|
377
411
|
}
|