wendkeep 0.68.0 → 0.68.1

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
@@ -4,6 +4,22 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.68.1] — 2026-08-08
8
+
9
+ ### Fixed
10
+
11
+ - **A contenção de locks do Vault deixa de expor `VAULT_PATH_UNSAFE` no Windows.** A revalidação de
12
+ uma falha transiente de resolução do lock público passa a decidir por um walk fresco do
13
+ componente, e não pelo `errno` reportado pela plataforma. O Windows devolve `UNKNOWN`, `EBADF` ou
14
+ `EPERM` onde o Linux devolve `ENOENT`, então a guarda de retry nunca disparava lá e promoções FLOW
15
+ concorrentes falhavam de forma intermitente com o código de fronteira física em vez do conflito de
16
+ promoção. Sufixo ausente ou diretório canônico estabilizado autorizam o retry; junction, symlink,
17
+ reparse point, componente redirecionado ou estado irresolvível persistente continuam falhando
18
+ fechado, e o orçamento de retry permanece único e limitado por aquisição.
19
+ - **Falha de lock nos caminhos FLOW reporta o código do domínio.** `withFlowPromotionLock` e o store
20
+ de sessão passam a propagar `FLOW_VAULT_BOUNDARY` para a fronteira física, alinhando a superfície
21
+ de erro ao resto da saga de promoção.
22
+
7
23
  ## [0.68.0] — 2026-08-02
8
24
 
9
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.68.0",
3
+ "version": "0.68.1",
4
4
  "description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -70,6 +70,6 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "acorn": "^8.18.0",
73
- "wendkeep": "^0.67.1"
73
+ "wendkeep": "^0.68.0"
74
74
  }
75
75
  }
@@ -84,10 +84,16 @@ function immutableJson(vaultBase, path, value) {
84
84
  return { created: true, path };
85
85
  }
86
86
 
87
+ // Lock failures on the FLOW paths surface with the domain boundary code, like every other
88
+ // Vault write in the promotion saga — not with the physical-boundary default.
87
89
  function underSessionLock(vaultBase, sessionId, fn) {
88
90
  const root = sessionRoot(vaultBase, sessionId);
89
- mkdirVaultPath(vaultBase, root, { label: 'raiz runtime da sessão FLOW' });
90
- const outcome = withVaultPathLock(vaultBase, join(root, '.state'), fn, { timeoutMs: 5000 });
91
+ mkdirVaultPath(vaultBase, root, {
92
+ label: 'raiz runtime da sessão FLOW', code: 'FLOW_VAULT_BOUNDARY',
93
+ });
94
+ const outcome = withVaultPathLock(vaultBase, join(root, '.state'), fn, {
95
+ timeoutMs: 5000, code: 'FLOW_VAULT_BOUNDARY',
96
+ });
91
97
  if (typeof outcome === 'symbol') {
92
98
  const error = new Error(`store FLOW ocupado para sessão ${sessionId}`);
93
99
  error.code = 'FLOW_STORE_BUSY';
@@ -107,10 +113,13 @@ export function withFlowPromotionLock(vaultBase, changeSlug, fn, {
107
113
  } = {}) {
108
114
  const slug = safeId(changeSlug, 'change_slug');
109
115
  const root = join(vaultBase, '.brain', 'runtime', 'flow-promotion-locks');
110
- mkdirVaultPath(vaultBase, root, { label: 'raiz de locks de promoção FLOW' });
116
+ mkdirVaultPath(vaultBase, root, {
117
+ label: 'raiz de locks de promoção FLOW', code: 'FLOW_VAULT_BOUNDARY',
118
+ });
111
119
  const outcome = withVaultPathLock(vaultBase, join(root, slug), fn, {
112
120
  timeoutMs,
113
121
  staleMs: ownerGraceMs,
122
+ code: 'FLOW_VAULT_BOUNDARY',
114
123
  });
115
124
  if (outcome === VAULT_LOCK_BUSY) {
116
125
  const busy = new Error(`promoção FLOW ocupada para a change ${slug}`);
@@ -335,19 +335,36 @@ function waitBriefly(ms) {
335
335
  Atomics.wait(signal, 0, 0, ms);
336
336
  }
337
337
 
338
- // A public lock may legitimately disappear while another owner releases it. Only ENOENT
339
- // observed by an operation explicitly scoped to that canonical lock receives bounded
340
- // backoff; private .pending paths and every unsafe topology fail closed.
341
- function retryablePublicLockError(lock, error, code, { allowRaw = true } = {}) {
338
+ // A public lock may legitimately disappear while another owner releases it. Structural shape
339
+ // only: which errno a concurrent removal produces is a platform detail — Windows reports
340
+ // UNKNOWN, EBADF or EPERM where Linux reports ENOENT so the code is merely the trigger to go
341
+ // re-observe, never the answer. Private .pending paths are siblings, not descendants, of the
342
+ // canonical lock and are excluded by the containment check.
343
+ function publicLockRetryCandidate(lock, error, code, { allowRaw = true } = {}) {
342
344
  const failure = error?.[VAULT_PATH_FAILURE];
345
+ // A raw error carries no component to re-observe, so it stays on the narrow ENOENT path.
343
346
  if (!failure) return allowRaw && error?.code === 'ENOENT' && !error?.cause;
344
- const causeCode = error?.cause?.code || failure.causeCode;
345
- return causeCode === 'ENOENT'
346
- && (error?.code === code || error?.code === 'VAULT_PATH_UNSAFE')
347
+ return (error?.code === code || error?.code === 'VAULT_PATH_UNSAFE')
347
348
  && ['component-realpath', 'component-missing'].includes(failure.kind)
348
349
  && containedBy(resolve(lock), resolve(failure.component));
349
350
  }
350
351
 
352
+ // The decision itself. Only a fresh walk that settles as a missing suffix or the canonical
353
+ // entry authorizes a retry; junction, symlink, reparse, a redirected component or a state that
354
+ // is still unresolvable keeps failing closed, exactly as a first-time validation would.
355
+ function publicLockComponentSettled(vaultBase, error, code) {
356
+ const component = error?.[VAULT_PATH_FAILURE]?.component;
357
+ if (typeof component !== 'string' || !component) return false;
358
+ try {
359
+ assertVaultPathSafe(vaultBase, component, {
360
+ label: 'revalidação do lock de escrita do Vault', code,
361
+ });
362
+ return true;
363
+ } catch {
364
+ return false;
365
+ }
366
+ }
367
+
351
368
  function vaultLockRenameCollision(error, pending, lock) {
352
369
  const acceptedCodes = process.platform === 'win32'
353
370
  ? ['EEXIST', 'ENOTEMPTY', 'EPERM']
@@ -374,17 +391,22 @@ function vaultLockRetryDeadlineError() {
374
391
  return error;
375
392
  }
376
393
 
377
- function withPublicLockRetry(lock, code, retryState, operation, initialError = null) {
394
+ function withPublicLockRetry(vaultBase, lock, code, retryState, operation, initialError = null) {
378
395
  let error = initialError;
379
396
  while (true) {
380
397
  if (error) {
381
- if (!retryablePublicLockError(lock, error, code)
398
+ if (!publicLockRetryCandidate(lock, error, code)
382
399
  || retryState.remaining <= 0) throw error;
383
400
  const remainingMs = retryState.deadline - Date.now();
384
401
  if (remainingMs <= 0) throw vaultLockRetryDeadlineError();
385
402
  retryState.remaining -= 1;
386
403
  waitBriefly(Math.min(VAULT_LOCK_TOPOLOGY_RETRY_MS, remainingMs));
387
404
  if (Date.now() >= retryState.deadline) throw vaultLockRetryDeadlineError();
405
+ // Reclassify only after the backoff, and only while the deadline still allows a retry:
406
+ // "settled" means observed once the concurrent owner had a chance to finish releasing.
407
+ // A raw error carries no component and already passed the narrow ENOENT filter above.
408
+ if (error[VAULT_PATH_FAILURE]
409
+ && !publicLockComponentSettled(vaultBase, error, code)) throw error;
388
410
  }
389
411
  try {
390
412
  return operation();
@@ -395,9 +417,11 @@ function withPublicLockRetry(lock, code, retryState, operation, initialError = n
395
417
  }
396
418
 
397
419
  function inspectVaultLock(vaultBase, lock, code, retryState, initialError = null) {
398
- return withPublicLockRetry(lock, code, retryState, () => assertVaultPathSafe(vaultBase, lock, {
399
- expectedType: 'directory', label: 'lock de escrita do Vault', code,
400
- }), initialError);
420
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => assertVaultPathSafe(
421
+ vaultBase, lock, {
422
+ expectedType: 'directory', label: 'lock de escrita do Vault', code,
423
+ },
424
+ ), initialError);
401
425
  }
402
426
 
403
427
  function processIsAlive(pid) {
@@ -447,7 +471,7 @@ function vaultLockOwner(vaultBase, lock, code, retryState = null) {
447
471
  }
448
472
  };
449
473
  return retryState
450
- ? withPublicLockRetry(lock, code, retryState, inspect)
474
+ ? withPublicLockRetry(vaultBase, lock, code, retryState, inspect)
451
475
  : inspect();
452
476
  }
453
477
 
@@ -492,7 +516,7 @@ function releaseOwnedVaultLock(vaultBase, lock, {
492
516
  // The token-specific lease is the filesystem CAS. An old finally/reaper can only
493
517
  // remove the directory after successfully unlinking the lease it originally saw;
494
518
  // a replacement lock never contains that unguessable path.
495
- const leaseRemoved = withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
519
+ const leaseRemoved = withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
496
520
  vaultBase, vaultLockLease(lock, token), {
497
521
  label: 'lease do lock de escrita do Vault', code,
498
522
  },
@@ -503,12 +527,12 @@ function releaseOwnedVaultLock(vaultBase, lock, {
503
527
  const current = currentState.owner;
504
528
  if (current?.pid !== pid || current?.token !== token) return false;
505
529
  const ownerPath = join(lock, VAULT_LOCK_OWNER_FILE);
506
- if (!withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
530
+ if (!withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
507
531
  vaultBase, ownerPath, {
508
532
  label: 'owner do lock de escrita do Vault', code,
509
533
  },
510
534
  ))) return false;
511
- return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
535
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
512
536
  vaultBase, lock, {
513
537
  missingOk: false, label: 'lock de escrita do Vault', code,
514
538
  },
@@ -516,7 +540,7 @@ function releaseOwnedVaultLock(vaultBase, lock, {
516
540
  }
517
541
 
518
542
  function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
519
- const initial = withPublicLockRetry(lock, code, retryState, () => {
543
+ const initial = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
520
544
  const checked = assertVaultPathSafe(vaultBase, lock, {
521
545
  expectedType: 'directory', label: 'lock de escrita do Vault', code,
522
546
  });
@@ -530,7 +554,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
530
554
  if (!observed.lockExists) return true;
531
555
  if (observed.owner) {
532
556
  if (processIsAlive(observed.owner.pid)) return false;
533
- const lease = withPublicLockRetry(lock, code, retryState, () => {
557
+ const lease = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
534
558
  const current = assertVaultPathSafe(vaultBase, lock, {
535
559
  expectedType: 'directory', label: 'lock de escrita do Vault', code,
536
560
  });
@@ -548,7 +572,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
548
572
  // Compatibility with owner-aware locks from 0.58.x, which predate token leases.
549
573
  // A dead PID plus byte-identical owner and directory identity is sufficient here;
550
574
  // a live legacy owner was returned above and is never reaped by age.
551
- const legacy = withPublicLockRetry(lock, code, retryState, () => {
575
+ const legacy = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
552
576
  const current = assertVaultPathSafe(vaultBase, lock, {
553
577
  expectedType: 'directory', label: 'lock legado de escrita do Vault', code,
554
578
  });
@@ -565,12 +589,12 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
565
589
  if (currentStat.birthtimeMs !== before.birthtimeMs
566
590
  || currentStat.mtimeMs !== before.mtimeMs
567
591
  || currentOwner.raw !== observed.raw) return false;
568
- if (!withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
592
+ if (!withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
569
593
  vaultBase, currentOwner.path, {
570
594
  label: 'owner legado morto do lock de escrita do Vault', code,
571
595
  },
572
596
  ))) return false;
573
- return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
597
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
574
598
  vaultBase, checked.target, {
575
599
  missingOk: false, label: 'lock legado de escrita do Vault', code,
576
600
  },
@@ -580,7 +604,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
580
604
  // Locks are published by atomic directory rename only after owner + lease exist.
581
605
  // Thus an old empty/partial directory is legacy or crash residue, never an in-flight
582
606
  // live acquisition. Unknown children remain fail-closed.
583
- const partial = withPublicLockRetry(lock, code, retryState, () => {
607
+ const partial = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
584
608
  const current = assertVaultPathSafe(vaultBase, lock, {
585
609
  expectedType: 'directory', label: 'lock de escrita do Vault', code,
586
610
  });
@@ -604,12 +628,12 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
604
628
  || currentStat.mtimeMs !== before.mtimeMs
605
629
  || currentOwner.raw !== observed.raw) return false;
606
630
  if (entries.includes(VAULT_LOCK_OWNER_FILE)
607
- && !withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
631
+ && !withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
608
632
  vaultBase, currentOwner.path, {
609
633
  label: 'owner parcial do lock de escrita do Vault', code,
610
634
  },
611
635
  ))) return false;
612
- return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
636
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
613
637
  vaultBase, checked.target, {
614
638
  missingOk: false, label: 'lock de escrita do Vault', code,
615
639
  },
@@ -672,7 +696,8 @@ export function withVaultPathLock(vaultBase, path, fn, {
672
696
  });
673
697
  break;
674
698
  } catch (error) {
675
- const retryableRenameRace = retryablePublicLockError(lock, error, code, {
699
+ // Shape check only; inspectVaultLock below re-observes through the full retry path.
700
+ const retryableRenameRace = publicLockRetryCandidate(lock, error, code, {
676
701
  allowRaw: false,
677
702
  });
678
703
  const nativeRenameCollision = vaultLockRenameCollision(error, pending, lock);