@qvac/sdk 0.17.0 → 0.18.0

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.
Files changed (154) hide show
  1. package/NOTICE +83 -18
  2. package/README.md +19 -0
  3. package/dist/client/api/audio-gen.d.ts +17 -0
  4. package/dist/client/api/audio-gen.js +17 -0
  5. package/dist/client/api/finetune.d.ts +2 -0
  6. package/dist/client/api/finetune.js +7 -1
  7. package/dist/client/api/index.d.ts +1 -1
  8. package/dist/client/api/index.js +1 -1
  9. package/dist/client/api/load-model.d.ts +8 -0
  10. package/dist/client/api/transcribe.d.ts +19 -2
  11. package/dist/client/api/transcribe.js +66 -27
  12. package/dist/client/api/translate.d.ts +3 -2
  13. package/dist/client/api/translate.js +42 -21
  14. package/dist/client/api/vla.d.ts +42 -1
  15. package/dist/client/api/vla.js +50 -1
  16. package/dist/commands/bundle/entry-gen.d.ts +4 -0
  17. package/dist/commands/bundle/entry-gen.js +6 -0
  18. package/dist/commands/bundle/index.js +19 -12
  19. package/dist/examples/asr/parakeet-indic-conformer-filesystem.d.ts +2 -0
  20. package/dist/examples/asr/parakeet-indic-conformer-filesystem.js +52 -0
  21. package/dist/examples/audiogen/generate-cover.d.ts +2 -0
  22. package/dist/examples/audiogen/generate-cover.js +100 -0
  23. package/dist/examples/llamacpp-fallback.d.ts +2 -0
  24. package/dist/examples/llamacpp-fallback.js +34 -0
  25. package/dist/examples/multi-job-completion.d.ts +28 -0
  26. package/dist/examples/multi-job-completion.js +128 -0
  27. package/dist/examples/profiling/basic.js +32 -1
  28. package/dist/examples/system-resources.d.ts +2 -0
  29. package/dist/examples/system-resources.js +86 -0
  30. package/dist/examples/tts/audio8.d.ts +2 -0
  31. package/dist/examples/tts/audio8.js +54 -0
  32. package/dist/examples/tts/cosyvoice3.d.ts +2 -0
  33. package/dist/examples/tts/cosyvoice3.js +52 -0
  34. package/dist/examples/vla-groot.js +92 -49
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +2 -2
  37. package/dist/models/registry/models.d.ts +782 -6
  38. package/dist/models/registry/models.js +2664 -1859
  39. package/dist/schemas/audio-gen.d.ts +103 -0
  40. package/dist/schemas/audio-gen.js +135 -4
  41. package/dist/schemas/common.d.ts +550 -11
  42. package/dist/schemas/constants-registry.d.ts +18 -4
  43. package/dist/schemas/constants-registry.js +16 -3
  44. package/dist/schemas/index.d.ts +1 -1
  45. package/dist/schemas/index.js +1 -1
  46. package/dist/schemas/llamacpp-config.d.ts +10 -10
  47. package/dist/schemas/llamacpp-config.js +12 -5
  48. package/dist/schemas/load-model.d.ts +3678 -323
  49. package/dist/schemas/load-model.js +21 -3
  50. package/dist/schemas/sdk-config.d.ts +60 -24
  51. package/dist/schemas/sdk-errors-server.d.ts +1 -0
  52. package/dist/schemas/sdk-errors-server.js +5 -0
  53. package/dist/schemas/text-to-speech.d.ts +1086 -110
  54. package/dist/schemas/text-to-speech.js +194 -4
  55. package/dist/schemas/tools.d.ts +0 -16
  56. package/dist/schemas/tools.js +0 -15
  57. package/dist/schemas/transcription-config.d.ts +2 -0
  58. package/dist/schemas/transcription-config.js +10 -4
  59. package/dist/schemas/transcription.d.ts +3 -0
  60. package/dist/schemas/vla.d.ts +83 -0
  61. package/dist/schemas/vla.js +105 -2
  62. package/dist/scripts/link-workspace-inference.d.ts +2 -0
  63. package/dist/scripts/link-workspace-inference.js +31 -0
  64. package/dist/server/bare/ops/cancel.d.ts +1 -1
  65. package/dist/server/bare/ops/cancel.js +1 -1
  66. package/dist/server/bare/ops/kv-cache-retention.js +11 -0
  67. package/dist/server/bare/ops/kv-cache-utils.d.ts +1 -1
  68. package/dist/server/bare/ops/kv-cache-utils.js +40 -12
  69. package/dist/server/bare/ops/transcribe.d.ts +2 -2
  70. package/dist/server/bare/ops/transcribe.js +54 -48
  71. package/dist/server/bare/ops/translate-stats.d.ts +5 -0
  72. package/dist/server/bare/ops/translate-stats.js +11 -0
  73. package/dist/server/bare/ops/translate.js +66 -38
  74. package/dist/server/bare/ops/unload-model.js +30 -24
  75. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-input.d.ts +20 -0
  76. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-input.js +153 -0
  77. package/dist/server/bare/plugins/audiogen-ggml/ops/audio-gen-stream.js +52 -1
  78. package/dist/server/bare/plugins/audiogen-ggml/plugin.d.ts +28 -0
  79. package/dist/server/bare/plugins/ggml-ocr/plugin.js +3 -1
  80. package/dist/server/bare/plugins/ggml-vla/ops/vla-set-embodiment.d.ts +3 -0
  81. package/dist/server/bare/plugins/ggml-vla/ops/vla-set-embodiment.js +9 -0
  82. package/dist/server/bare/plugins/ggml-vla/plugin.d.ts +37 -0
  83. package/dist/server/bare/plugins/ggml-vla/plugin.js +21 -4
  84. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-completion-stream.js +16 -15
  85. package/dist/server/bare/plugins/llamacpp-completion/ops/cache-logger.d.ts +1 -1
  86. package/dist/server/bare/plugins/llamacpp-completion/ops/cache-logger.js +2 -2
  87. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stats.d.ts +6 -0
  88. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stats.js +10 -0
  89. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stream.d.ts +1 -0
  90. package/dist/server/bare/plugins/llamacpp-completion/ops/completion-stream.js +131 -123
  91. package/dist/server/bare/plugins/llamacpp-completion/ops/finetune.js +110 -20
  92. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-session.d.ts +49 -23
  93. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-session.js +308 -143
  94. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-state.d.ts +1 -0
  95. package/dist/server/bare/plugins/llamacpp-completion/ops/kv-cache-state.js +2 -2
  96. package/dist/server/bare/plugins/llamacpp-completion/plugin.d.ts +6 -6
  97. package/dist/server/bare/plugins/llamacpp-completion/plugin.js +55 -36
  98. package/dist/server/bare/plugins/llamacpp-completion/transform.js +1 -7
  99. package/dist/server/bare/plugins/llamacpp-embedding/plugin.js +2 -3
  100. package/dist/server/bare/plugins/nmtcpp-translation/plugin.js +6 -5
  101. package/dist/server/bare/plugins/parakeet-transcription/plugin.js +27 -16
  102. package/dist/server/bare/plugins/tts-ggml/ops/parler-options.js +23 -3
  103. package/dist/server/bare/plugins/tts-ggml/plugin.d.ts +302 -4
  104. package/dist/server/bare/plugins/tts-ggml/plugin.js +131 -0
  105. package/dist/server/bare/plugins/whispercpp-transcription/plugin.js +48 -39
  106. package/dist/server/bare/registry/model-registry.d.ts +1 -0
  107. package/dist/server/bare/runtime/request-registry-singleton.js +29 -15
  108. package/dist/server/bare/runtime/request-registry.d.ts +27 -0
  109. package/dist/server/bare/runtime/request-registry.js +246 -76
  110. package/dist/server/bare/types/addon-responses.d.ts +4 -12
  111. package/dist/server/rpc/handlers/load-model/handler.js +2 -2
  112. package/dist/server/rpc/handlers/load-model/resolve-session.d.ts +1 -1
  113. package/dist/server/rpc/handlers/load-model/resolve-session.js +3 -3
  114. package/dist/server/rpc/handlers/load-model/resolve.d.ts +12 -2
  115. package/dist/server/rpc/handlers/load-model/resolve.js +90 -12
  116. package/dist/server/utils/audio/decoder.d.ts +37 -1
  117. package/dist/server/utils/audio/decoder.js +81 -51
  118. package/dist/server/utils/config-transform.d.ts +3 -0
  119. package/dist/server/utils/config-transform.js +6 -0
  120. package/dist/server/utils/index.d.ts +0 -1
  121. package/dist/server/utils/index.js +0 -1
  122. package/dist/server/utils/shard-utils.d.ts +1 -0
  123. package/dist/server/utils/shard-utils.js +14 -0
  124. package/dist/server/utils/tool-integration.d.ts +3 -10
  125. package/dist/server/utils/tool-integration.js +3 -12
  126. package/dist/utils/errors-server.d.ts +3 -0
  127. package/dist/utils/errors-server.js +5 -0
  128. package/package.json +12 -14
  129. package/dist/examples/llamacpp-dynamic-tools.d.ts +0 -2
  130. package/dist/examples/llamacpp-dynamic-tools.js +0 -152
  131. package/dist/models/update-models/codegen.d.ts +0 -3
  132. package/dist/models/update-models/codegen.js +0 -196
  133. package/dist/models/update-models/companions.d.ts +0 -30
  134. package/dist/models/update-models/companions.js +0 -296
  135. package/dist/models/update-models/history.d.ts +0 -24
  136. package/dist/models/update-models/history.js +0 -107
  137. package/dist/models/update-models/index.d.ts +0 -2
  138. package/dist/models/update-models/index.js +0 -123
  139. package/dist/models/update-models/naming.d.ts +0 -4
  140. package/dist/models/update-models/naming.js +0 -396
  141. package/dist/models/update-models/processing.d.ts +0 -8
  142. package/dist/models/update-models/processing.js +0 -65
  143. package/dist/models/update-models/registry.d.ts +0 -5
  144. package/dist/models/update-models/registry.js +0 -87
  145. package/dist/models/update-models/shards.d.ts +0 -4
  146. package/dist/models/update-models/shards.js +0 -57
  147. package/dist/models/update-models/types.d.ts +0 -88
  148. package/dist/models/update-models/types.js +0 -1
  149. package/dist/models/update-models/utils.d.ts +0 -3
  150. package/dist/models/update-models/utils.js +0 -21
  151. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-cancelled.d.ts +0 -23
  152. package/dist/server/bare/plugins/llamacpp-completion/ops/batch-cancelled.js +0 -30
  153. package/dist/server/utils/expand-gguf-shards.d.ts +0 -3
  154. package/dist/server/utils/expand-gguf-shards.js +0 -22
@@ -11,36 +11,22 @@ import { getServerLogger } from '../../../../../logging/index.js';
11
11
  // the caller — typically `withRequestContext(...)`.
12
12
  const moduleLogger = getServerLogger();
13
13
  /**
14
- * Coordinates five KV-cache state layers:
15
- *
16
- * 1. `cachedMessageCounts` — saved message boundaries.
17
- * 2. `initializedCaches` — caches primed in this worker.
18
- * 3. On-disk `.bin` files written by the addon.
19
- * 4. `activeCachePaths` — per-path refs that block in-flight eviction.
20
- * 5. `.auto-cache-<key>` markers — SDK-generated cache ownership.
21
- *
22
- * Every turn must finish through `commitTurn` or `rollback` so all
23
- * inference state stays aligned, the active-path ref is released, and
24
- * marker metadata follows the cache directory lifecycle.
14
+ * What the kv-cache file on disk is known to cover, keyed by cache path.
15
+ * Written by `commitTurn`, read by `getSavedCount`, deleted by `rollback` /
16
+ * `delete` / `dropStaleSavedCount`. The same INVARIANT that existed in
17
+ * `kv-cache-state.ts` still holds: an entry is present only when the
18
+ * corresponding `.bin` file is considered trustworthy. Cancelled or
19
+ * zero-token turns must remove the entry so the next-turn slice doesn't read
20
+ * a stale boundary.
25
21
  */
26
- // ----- module-scoped state. The session is the single mutation point
27
- // for the in-memory KV-cache bookkeeping. -----
28
- /**
29
- * Number of chat messages the kv-cache file on disk is known to cover,
30
- * keyed by cache path. Written by `commitTurn`, read by `getSavedCount`,
31
- * deleted by `rollback` / `delete` / `dropStaleSavedCount`. The same
32
- * INVARIANT that existed in `kv-cache-state.ts` still holds: an entry is
33
- * present only when the corresponding `.bin` file is considered
34
- * trustworthy. Cancelled or zero-token turns must remove the entry so
35
- * the next-turn slice doesn't read a stale boundary.
36
- */
37
- const cachedMessageCounts = new Map();
22
+ const cachedPrefixes = new Map();
38
23
  /**
39
24
  * In-memory registry of caches initialized this session. The addon
40
25
  * defers disk writes, so the absence of a `.bin` file on disk isn't
41
26
  * proof that the cache hasn't been primed in this worker process. Keyed
42
- * by `${modelId}:${configHash}:${cacheKey}`, so on-disk caches from
43
- * older worker runs still hit the lazy-load path in `beginTurn`.
27
+ * by the resolved cache path, so aliased keys that name one file share an
28
+ * entry and on-disk caches from older worker runs still hit the lazy-load
29
+ * path in `beginTurn`.
44
30
  */
45
31
  const initializedCaches = new Set();
46
32
  const activeCachePaths = new Map();
@@ -51,8 +37,72 @@ const AUTO_CACHE_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
51
37
  let lastAutoCacheSweepMs = 0;
52
38
  let autoCacheSweepInFlight = null;
53
39
  let cacheStateLockTail = Promise.resolve();
54
- function initRegistryKey(modelId, configHash, cacheKey) {
55
- return `${modelId}:${configHash}:${cacheKey}`;
40
+ const cachePathLocks = new Map();
41
+ // Internal sentinel so acquisition paths prune directories/markers only when a
42
+ // queued lock wait was aborted, not when lock acquisition failed for another
43
+ // reason.
44
+ class CacheLockAbortError extends Error {
45
+ constructor(cause) {
46
+ super('cache lock wait aborted', cause !== undefined ? { cause } : undefined);
47
+ this.name = 'CacheLockAbortError';
48
+ }
49
+ }
50
+ // Case-fold the lock-map key so case-only path variants (e.g. "Session" vs
51
+ // "session"), which name the SAME file on case-insensitive filesystems (default
52
+ // macOS/Windows), serialise on one lock and can't interleave writes. Over-locks
53
+ // case-only variants on case-sensitive filesystems, which is safe. NOTE: the
54
+ // other per-path bookkeeping (activeCachePaths, cachedPrefixes, retention)
55
+ // stays case-sensitive, so case-only key variants are otherwise unsupported —
56
+ // see the KV-cache system doc.
57
+ function lockKeyFor(cachePath) {
58
+ return cachePath.toLowerCase();
59
+ }
60
+ function releaseCachePathWriteLock(lockKey) {
61
+ const lock = cachePathLocks.get(lockKey);
62
+ if (!lock)
63
+ return;
64
+ const next = lock.waiters.shift();
65
+ if (next) {
66
+ next.grant();
67
+ return;
68
+ }
69
+ lock.held = false;
70
+ cachePathLocks.delete(lockKey);
71
+ }
72
+ // Returns the release fn. Aborting while queued rejects and removes the waiter.
73
+ async function acquireCachePathWriteLock(cachePath, signal) {
74
+ const lockKey = lockKeyFor(cachePath);
75
+ let lock = cachePathLocks.get(lockKey);
76
+ if (!lock) {
77
+ lock = { held: false, waiters: [] };
78
+ cachePathLocks.set(lockKey, lock);
79
+ }
80
+ if (!lock.held) {
81
+ lock.held = true;
82
+ return () => releaseCachePathWriteLock(lockKey);
83
+ }
84
+ await new Promise((resolve, reject) => {
85
+ const waiter = {
86
+ grant: () => {
87
+ signal?.removeEventListener('abort', onAbort);
88
+ resolve();
89
+ },
90
+ drop: (reason) => reject(reason)
91
+ };
92
+ const onAbort = () => {
93
+ const index = lock.waiters.indexOf(waiter);
94
+ if (index !== -1)
95
+ lock.waiters.splice(index, 1);
96
+ waiter.drop(new CacheLockAbortError(signal?.reason));
97
+ };
98
+ if (signal?.aborted) {
99
+ onAbort();
100
+ return;
101
+ }
102
+ lock.waiters.push(waiter);
103
+ signal?.addEventListener('abort', onAbort, { once: true });
104
+ });
105
+ return () => releaseCachePathWriteLock(lockKey);
56
106
  }
57
107
  function markCachePathActive(cachePath) {
58
108
  activeCachePaths.set(cachePath, (activeCachePaths.get(cachePath) ?? 0) + 1);
@@ -67,6 +117,10 @@ function releaseCachePath(cachePath) {
67
117
  }
68
118
  activeCachePaths.set(cachePath, count - 1);
69
119
  }
120
+ // Paths in-flight turns hold, so pruning skips a directory still in use.
121
+ function snapshotActivePaths() {
122
+ return Array.from(activeCachePaths.keys());
123
+ }
70
124
  function isCacheKeyActive(cacheKey) {
71
125
  const cacheDirectory = path.join(getKVCacheDir(), cacheKey);
72
126
  return Array.from(activeCachePaths.keys()).some((cachePath) => isCachePathWithinDirectory(cacheDirectory, cachePath));
@@ -151,15 +205,18 @@ export function createKvCacheSession(modelId, options) {
151
205
  // WeakMap so handles drop their state once the handler scope releases
152
206
  // the reference; the module-scoped maps above survive.
153
207
  const turnState = new WeakMap();
154
- function makeHandle(cachePath, registryKey, autoCacheKey) {
208
+ function makeHandle(cachePath, autoCacheKey, releaseWriteLock = () => { }, signal) {
209
+ const cached = cachedPrefixes.get(cachePath);
155
210
  const handle = {
156
211
  cachePath,
157
- savedCount: cachedMessageCounts.get(cachePath) ?? 0
212
+ savedCount: cached?.messages ?? 0,
213
+ toolBlockCached: cached?.toolBlock ?? false
158
214
  };
159
215
  turnState.set(handle, {
160
216
  cachePath,
161
- registryKey,
162
217
  ...(autoCacheKey !== undefined && { autoCacheKey }),
218
+ ...(signal !== undefined && { signal }),
219
+ releaseWriteLock,
163
220
  committed: false,
164
221
  rolledBack: false
165
222
  });
@@ -168,19 +225,41 @@ export function createKvCacheSession(modelId, options) {
168
225
  }
169
226
  async function beginCustom(input) {
170
227
  const cachePath = await getCacheFilePath(modelId, input.configHash, input.customKey);
171
- const registryKey = initRegistryKey(modelId, input.configHash, input.customKey);
172
- const handle = makeHandle(cachePath, registryKey);
228
+ // Held for the whole turn so a same-file peer can't interleave writes.
229
+ // Released on commit/rollback/error; throws if aborted while queued.
230
+ let releaseWriteLock;
231
+ try {
232
+ releaseWriteLock = await acquireCachePathWriteLock(cachePath, input.signal);
233
+ }
234
+ catch (error) {
235
+ // Aborted while queued: no lock is held to release, and getCacheFilePath
236
+ // already mkdir'd this key's parent (a distinct dir under a case variant),
237
+ // so prune it before the cancellation propagates.
238
+ if (error instanceof CacheLockAbortError) {
239
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
240
+ }
241
+ throw error;
242
+ }
243
+ // A turn cancelled by the time it holds the lock must not prime (native work).
244
+ // getCacheFilePath already mkdir'd the parent, so prune it before surfacing
245
+ // the cancellation the plugin rides.
246
+ if (input.signal?.aborted) {
247
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
248
+ releaseWriteLock();
249
+ throw new CacheLockAbortError(input.signal.reason);
250
+ }
251
+ const handle = makeHandle(cachePath, undefined, releaseWriteLock, input.signal);
173
252
  try {
174
253
  // In-memory registry check first — the addon defers disk writes, so
175
254
  // a freshly-primed cache may not yet exist on disk. If the
176
255
  // in-memory flag isn't set, fall back to a filesystem probe so
177
256
  // caches surviving across worker restarts still hit the reuse path.
178
- let exists = initializedCaches.has(registryKey);
257
+ let exists = initializedCaches.has(cachePath);
179
258
  if (!exists) {
180
259
  try {
181
260
  await fsPromises.access(cachePath);
182
261
  exists = true;
183
- initializedCaches.add(registryKey);
262
+ initializedCaches.add(cachePath);
184
263
  }
185
264
  catch {
186
265
  exists = false;
@@ -188,49 +267,109 @@ export function createKvCacheSession(modelId, options) {
188
267
  }
189
268
  logCacheStatus(input.customKey, exists);
190
269
  if (!exists) {
270
+ // Recreate the parent dir if a same-key peer's rollback pruned it after our lock wait.
271
+ await fsPromises.mkdir(path.dirname(cachePath), { recursive: true });
272
+ // The access probe / mkdir above yielded, so a cancel may have landed
273
+ // since the acquire-time check — re-check right before native priming.
274
+ // The catch below prunes the directory just created.
275
+ if (input.signal?.aborted)
276
+ throw new CacheLockAbortError(input.signal.reason);
191
277
  await input.primeIfMissing(cachePath);
192
278
  await verifyPrimedFile(cachePath, logger);
193
- initializedCaches.add(registryKey);
279
+ initializedCaches.add(cachePath);
194
280
  }
195
281
  return handle;
196
282
  }
197
283
  catch (error) {
198
284
  releaseCachePath(cachePath);
285
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
286
+ releaseWriteLock();
199
287
  throw error;
200
288
  }
201
289
  }
202
290
  async function beginAuto(input) {
203
- // The pre-response cache key is derived from
204
- // `history.slice(0, -1)` — `findMatchingCache` does that
205
- // internally. The post-response key (used after a successful turn)
206
- // is computed by the caller and passed to `commitTurn` as
207
- // `targetCachePath`.
208
- const setup = await withCacheStateLock(async () => {
291
+ // The path comes from a history lookup, so: discover it, lock it (outside
292
+ // the state lock, or commitTurn's rename could deadlock a waiter), then
293
+ // re-check existence under the lock in case a same-file peer just renamed it.
294
+ const discovered = await withCacheStateLock(async () => {
209
295
  const existingCache = await findMatchingCache(modelId, input.configHash, input.history);
210
296
  const cacheInfo = existingCache ?? (await getCurrentCacheInfo(modelId, input.configHash, input.history));
211
- const registryKey = initRegistryKey(modelId, input.configHash, cacheInfo.cacheKey);
212
297
  return {
213
- cacheExists: existingCache !== null,
214
298
  cachePath: cacheInfo.cachePath,
215
- cacheKey: cacheInfo.cacheKey,
216
- registryKey,
217
- handle: makeHandle(cacheInfo.cachePath, registryKey, cacheInfo.cacheKey)
299
+ cacheKey: cacheInfo.cacheKey
218
300
  };
219
301
  });
220
- const { cacheExists, cachePath, cacheKey, registryKey, handle } = setup;
302
+ const { cachePath, cacheKey } = discovered;
303
+ let releaseWriteLock;
304
+ try {
305
+ releaseWriteLock = await acquireCachePathWriteLock(cachePath, input.signal);
306
+ }
307
+ catch (error) {
308
+ // Aborted while queued: no lock is held to release. Discovery already
309
+ // mkdir'd the parent and wrote the auto marker; clean both before the
310
+ // cancellation propagates.
311
+ if (error instanceof CacheLockAbortError) {
312
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
313
+ await removeAutoCacheMarkerIfMissing(cacheKey);
314
+ }
315
+ throw error;
316
+ }
317
+ // A turn cancelled by the time it holds the lock must not prime (native work).
318
+ // Discovery already mkdir'd the parent and wrote the auto marker; clean both
319
+ // before surfacing the cancellation.
320
+ if (input.signal?.aborted) {
321
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
322
+ await removeAutoCacheMarkerIfMissing(cacheKey);
323
+ releaseWriteLock();
324
+ throw new CacheLockAbortError(input.signal.reason);
325
+ }
326
+ let handle;
327
+ let cacheExists;
328
+ try {
329
+ const resolved = await withCacheStateLock(async () => {
330
+ // Mark the path active under the state lock so retention can't evict it.
331
+ const h = makeHandle(cachePath, cacheKey, releaseWriteLock, input.signal);
332
+ let exists = initializedCaches.has(cachePath);
333
+ if (!exists) {
334
+ try {
335
+ await fsPromises.access(cachePath);
336
+ exists = true;
337
+ initializedCaches.add(cachePath);
338
+ }
339
+ catch {
340
+ exists = false;
341
+ }
342
+ }
343
+ return { h, exists };
344
+ });
345
+ handle = resolved.h;
346
+ cacheExists = resolved.exists;
347
+ }
348
+ catch (error) {
349
+ releaseWriteLock();
350
+ throw error;
351
+ }
221
352
  logCacheStatus('auto', cacheExists);
222
353
  try {
223
354
  if (!cacheExists) {
355
+ // Recreate the parent dir if a same-file peer's rename pruned it.
356
+ await fsPromises.mkdir(path.dirname(cachePath), { recursive: true });
357
+ // The discovery / mkdir above yielded, so a cancel may have landed since
358
+ // the acquire-time check — re-check right before native priming. The
359
+ // catch below prunes the directory and auto marker.
360
+ if (input.signal?.aborted)
361
+ throw new CacheLockAbortError(input.signal.reason);
224
362
  await input.primeIfMissing(cachePath);
225
363
  await verifyPrimedFile(cachePath, logger);
226
- initializedCaches.add(registryKey);
364
+ initializedCaches.add(cachePath);
227
365
  }
228
366
  return handle;
229
367
  }
230
368
  catch (error) {
231
369
  releaseCachePath(cachePath);
232
- await pruneEmptyCacheDirectories(cachePath);
370
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
233
371
  await removeAutoCacheMarkerIfMissing(cacheKey);
372
+ releaseWriteLock();
234
373
  throw error;
235
374
  }
236
375
  }
@@ -255,7 +394,7 @@ export function createKvCacheSession(modelId, options) {
255
394
  // at the same path. Verify the file persisted (the addon
256
395
  // currently swallows save errors — see TODO in
257
396
  // `verifySaveAndRecord`) and record the new boundary.
258
- const ok = await verifySaveAndRecord(state.cachePath, result.messageCount);
397
+ const ok = await verifySaveAndRecord(state.cachePath, result.messageCount, result.toolBlockCached);
259
398
  if (!ok) {
260
399
  // The expected save didn't land — treat the turn as a rollback
261
400
  // so the next turn re-primes cleanly.
@@ -264,51 +403,79 @@ export function createKvCacheSession(modelId, options) {
264
403
  }
265
404
  state.committed = true;
266
405
  releaseCachePath(state.cachePath);
406
+ state.releaseWriteLock();
267
407
  return;
268
408
  }
269
409
  const sourceCachePath = state.cachePath;
270
410
  const sourceCacheKey = state.autoCacheKey;
271
411
  const targetCacheKey = path.basename(path.dirname(path.dirname(result.targetCachePath)));
272
- const renamed = await withCacheStateLock(async () => {
273
- markCachePathActive(result.targetCachePath);
274
- await fsPromises.mkdir(path.dirname(result.targetCachePath), { recursive: true });
275
- await markAutoCacheKey(targetCacheKey);
276
- if (!(await renameCacheFile(sourceCachePath, result.targetCachePath))) {
277
- releaseCachePath(result.targetCachePath);
278
- await pruneEmptyCacheDirectories(result.targetCachePath);
279
- await removeAutoCacheMarkerIfMissing(targetCacheKey);
280
- return false;
412
+ // Hold the target's lock across the rename AND commit verification, so a peer
413
+ // resolving to the same file can't observe it before its saved count is
414
+ // published. Abortable: a cancel while waiting for the lock rejects here.
415
+ const releaseTargetLock = await acquireCachePathWriteLock(result.targetCachePath, state.signal);
416
+ try {
417
+ // A cancel that landed while we waited for the target lock must not commit.
418
+ if (state.signal?.aborted) {
419
+ await runRollback(state);
420
+ return;
281
421
  }
282
- releaseCachePath(sourceCachePath);
283
- state.cachePath = result.targetCachePath;
284
- state.autoCacheKey = targetCacheKey;
285
- await pruneEmptyCacheDirectories(sourceCachePath);
286
- if (sourceCacheKey !== undefined) {
287
- await removeAutoCacheMarkerIfMissing(sourceCacheKey);
422
+ const renamed = await withCacheStateLock(async () => {
423
+ markCachePathActive(result.targetCachePath);
424
+ try {
425
+ await fsPromises.mkdir(path.dirname(result.targetCachePath), { recursive: true });
426
+ await markAutoCacheKey(targetCacheKey);
427
+ if (!(await renameCacheFile(sourceCachePath, result.targetCachePath))) {
428
+ releaseCachePath(result.targetCachePath);
429
+ await pruneEmptyCacheDirectories(result.targetCachePath, snapshotActivePaths());
430
+ await removeAutoCacheMarkerIfMissing(targetCacheKey);
431
+ return false;
432
+ }
433
+ }
434
+ catch (setupError) {
435
+ // mkdir/markAutoCacheKey/rename threw after the target active-ref was
436
+ // taken: release it (first, so it can't leak) and prune any directory
437
+ // or marker created above, then propagate.
438
+ releaseCachePath(result.targetCachePath);
439
+ await pruneEmptyCacheDirectories(result.targetCachePath, snapshotActivePaths());
440
+ await removeAutoCacheMarkerIfMissing(targetCacheKey);
441
+ throw setupError;
442
+ }
443
+ releaseCachePath(sourceCachePath);
444
+ state.cachePath = result.targetCachePath;
445
+ state.autoCacheKey = targetCacheKey;
446
+ await pruneEmptyCacheDirectories(sourceCachePath, snapshotActivePaths());
447
+ if (sourceCacheKey !== undefined) {
448
+ await removeAutoCacheMarkerIfMissing(sourceCacheKey);
449
+ }
450
+ cachedPrefixes.delete(sourceCachePath);
451
+ // state.cachePath was just reassigned to the target; clear the SOURCE
452
+ // entry, not the freshly-valid target.
453
+ initializedCaches.delete(sourceCachePath);
454
+ return true;
455
+ });
456
+ if (!renamed) {
457
+ logger.warn(`[kv-cache] Auto cache rename failed; rolling back. from=${sourceCachePath} to=${result.targetCachePath}`);
458
+ await runRollback(state);
459
+ return;
288
460
  }
289
- cachedMessageCounts.delete(sourceCachePath);
290
- initializedCaches.delete(state.registryKey);
291
- return true;
292
- });
293
- if (!renamed) {
294
- logger.warn(`[kv-cache] Auto cache rename failed; rolling back. from=${sourceCachePath} to=${result.targetCachePath}`);
295
- await runRollback(state);
296
- return;
461
+ const ok = await verifySaveAndRecord(result.targetCachePath, result.messageCount, result.toolBlockCached);
462
+ if (!ok) {
463
+ // Rename succeeded but the file isn't where we expected. Roll back via
464
+ // the target path instead of the (now-empty) source.
465
+ await runRollback(state);
466
+ return;
467
+ }
468
+ // Successful auto-rename. The handle's `cachePath` field still points at
469
+ // the (now-gone) source path — fine, the handle is committed and won't
470
+ // roll back. Future turns compute fresh paths.
471
+ state.committed = true;
472
+ releaseCachePath(state.cachePath);
473
+ state.releaseWriteLock();
474
+ scheduleAutoCacheSweep(logger);
297
475
  }
298
- const ok = await verifySaveAndRecord(result.targetCachePath, result.messageCount);
299
- if (!ok) {
300
- // Rename succeeded but the file isn't where we expected. Roll
301
- // back via the target path instead of the (now-empty) source.
302
- await runRollback(state);
303
- return;
476
+ finally {
477
+ releaseTargetLock();
304
478
  }
305
- // Successful auto-rename. The handle's `cachePath` field still
306
- // points at the (now-gone) source path — that's fine, the handle
307
- // is committed and won't roll back. Future turns compute fresh
308
- // paths.
309
- state.committed = true;
310
- releaseCachePath(state.cachePath);
311
- scheduleAutoCacheSweep(logger);
312
479
  }
313
480
  async function rollback(turn) {
314
481
  const state = turnState.get(turn);
@@ -329,14 +496,17 @@ export function createKvCacheSession(modelId, options) {
329
496
  catch (unlinkError) {
330
497
  logger.warn(`[kv-cache] Failed to remove cache file during rollback; next turn may load stale KV state. path=${state.cachePath} error=${unlinkError instanceof Error ? unlinkError.message : String(unlinkError)}`);
331
498
  }
332
- await pruneEmptyCacheDirectories(state.cachePath);
499
+ // Release before pruning so an empty parent can go; a sibling still holding
500
+ // the path keeps it in the active snapshot and protects the directory.
501
+ releaseCachePath(state.cachePath);
502
+ await pruneEmptyCacheDirectories(state.cachePath, snapshotActivePaths());
333
503
  if (state.autoCacheKey !== undefined) {
334
504
  await removeAutoCacheMarkerIfMissing(state.autoCacheKey);
335
505
  }
336
- initializedCaches.delete(state.registryKey);
337
- cachedMessageCounts.delete(state.cachePath);
338
- releaseCachePath(state.cachePath);
506
+ initializedCaches.delete(state.cachePath);
507
+ cachedPrefixes.delete(state.cachePath);
339
508
  state.rolledBack = true;
509
+ state.releaseWriteLock();
340
510
  if (state.autoCacheKey !== undefined)
341
511
  scheduleAutoCacheSweep(logger);
342
512
  }
@@ -344,7 +514,7 @@ export function createKvCacheSession(modelId, options) {
344
514
  const state = turnState.get(turn);
345
515
  if (!state)
346
516
  return;
347
- cachedMessageCounts.delete(state.cachePath);
517
+ cachedPrefixes.delete(state.cachePath);
348
518
  }
349
519
  return {
350
520
  beginTurn,
@@ -370,24 +540,26 @@ export function createKvCacheSession(modelId, options) {
370
540
  * Layers cleared, in order:
371
541
  * 1. On-disk: `deleteCache(...)` removes the matching directory
372
542
  * tree (or wipes and recreates the root for `all: true`).
373
- * 2. `cachedMessageCounts`: prefix-cleanup by the removed directory
543
+ * 2. `cachedPrefixes`: prefix-cleanup by the removed directory
374
544
  * so any per-cache count under the deleted tree is forgotten.
375
- * 3. `initializedCaches`: scope clear by `(kvCacheKey[, modelId])`,
376
- * matching the on-disk scope.
545
+ * 3. `initializedCaches`: prefix-cleanup by the same removed
546
+ * directory, since it is keyed by the resolved cache path.
377
547
  *
378
- * Concurrency with in-flight turns: this delete is wire-async with
379
- * respect to any turn currently holding a `TurnHandle` for the same
380
- * cache key. Worst case the on-disk `.bin` is removed while a turn is
381
- * mid-write; the turn's eventual `commitTurn(...)` then fails the
382
- * `verifySaveAndRecord` probe (file gone) and rolls back idempotently.
383
- * No coordination primitive is needed because every layer's mutation
384
- * is idempotent (`unlink` no-ops if missing, `Map.delete` / `Set.delete`
385
- * no-op on absent keys).
548
+ * Concurrency with in-flight turns: this delete does not take the
549
+ * per-cache-path write locks, so it races any turn holding a `TurnHandle`
550
+ * for the same key. Deleting a key that is in active use is unsupported.
551
+ * Each individual mutation is idempotent (`unlink` no-ops if missing,
552
+ * `Map.delete` / `Set.delete` no-op on absent keys), but the layers are
553
+ * cleared without a lock, so an interleaving that lands the delete's
554
+ * in-memory cleanup after a concurrent turn has already renamed/committed
555
+ * its file splits state: the file stays on disk while its saved-count and
556
+ * init flag are cleared, so the next turn sees the file, skips priming, and
557
+ * reports `savedCount=0`. Callers must not delete a key that is in active use.
386
558
  */
387
559
  export async function deleteKvCacheState(target) {
388
560
  if ('all' in target) {
389
561
  const removed = await deleteCacheUtil({ all: true });
390
- cachedMessageCounts.clear();
562
+ cachedPrefixes.clear();
391
563
  initializedCaches.clear();
392
564
  // `removed` is the kv-cache root dir; surfaces it for ops
393
565
  // visibility but isn't part of the contract.
@@ -399,20 +571,20 @@ export async function deleteKvCacheState(target) {
399
571
  ...(target.modelId !== undefined && { modelId: target.modelId })
400
572
  });
401
573
  if (target.modelId === undefined) {
402
- await removeAutoCacheMarker(target.kvCacheKey);
574
+ // Remove the marker by the root-relative key: an alias like "./<16hex>"
575
+ // still resolves to the canonical auto key, while a nested key such as
576
+ // "tenant/<16hex>" stays distinct — path.basename would wrongly collapse it
577
+ // onto the unrelated top-level auto marker.
578
+ await removeAutoCacheMarker(path.relative(getKVCacheDir(), removedPath));
403
579
  }
404
580
  // Prefix-cleanup the in-memory counts. The on-disk directory tree
405
581
  // is `{kvCacheRoot}/{kvCacheKey}[/{modelId}]/`, so every entry in
406
- // `cachedMessageCounts` whose key is the removed directory itself
582
+ // `cachedPrefixes` whose key is the removed directory itself
407
583
  // or sits beneath it must go.
408
584
  clearCachedMessageCountsByPrefix(removedPath, path.sep);
409
- // The in-memory init-set keys are
410
- // `${modelId}:${configHash}:${kvCacheKey}` — clear by the user-
411
- // facing kvCacheKey (and optionally narrow by modelId).
412
- clearInitializedCachesByScope({
413
- cacheKey: target.kvCacheKey,
414
- ...(target.modelId !== undefined && { modelId: target.modelId })
415
- });
585
+ // initializedCaches is keyed by the resolved cachePath too, so clear it by
586
+ // the same removed-directory prefix as cachedPrefixes above.
587
+ clearInitializedCachesByPrefix(removedPath, path.sep);
416
588
  }
417
589
  // ----- private helpers -----
418
590
  /**
@@ -454,7 +626,7 @@ async function verifyPrimedFile(cachePath, logger) {
454
626
  catch (statError) {
455
627
  // ENOENT is the common case here — addon prime returned without
456
628
  // calling save (most often: signal abort during prefill).
457
- await pruneEmptyCacheDirectories(cachePath);
629
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
458
630
  throw new Error(`[kv-cache] prime closure resolved but no cache file was written. path=${cachePath} cause=${statError instanceof Error ? statError.message : String(statError)}`);
459
631
  }
460
632
  if (stats.size === 0) {
@@ -463,7 +635,7 @@ async function verifyPrimedFile(cachePath, logger) {
463
635
  // primary "prime didn't persist" condition.
464
636
  try {
465
637
  await fsPromises.unlink(cachePath);
466
- await pruneEmptyCacheDirectories(cachePath);
638
+ await pruneEmptyCacheDirectories(cachePath, snapshotActivePaths());
467
639
  }
468
640
  catch (unlinkError) {
469
641
  logger.warn(`[kv-cache] Failed to remove empty primed cache file. path=${cachePath} error=${unlinkError instanceof Error ? unlinkError.message : String(unlinkError)}`);
@@ -482,52 +654,41 @@ async function verifyPrimedFile(cachePath, logger) {
482
654
  * false), drop the `access()` probe and wrap the `model.run()` call in
483
655
  * a real try/catch that forwards the error.
484
656
  */
485
- async function verifySaveAndRecord(cachePath, messageCount) {
657
+ async function verifySaveAndRecord(cachePath, messageCount, toolBlockCached) {
486
658
  try {
487
659
  await fsPromises.access(cachePath);
488
- cachedMessageCounts.set(cachePath, messageCount);
660
+ cachedPrefixes.set(cachePath, { messages: messageCount, toolBlock: toolBlockCached });
489
661
  return true;
490
662
  }
491
663
  catch (err) {
492
- cachedMessageCounts.delete(cachePath);
664
+ cachedPrefixes.delete(cachePath);
493
665
  logCacheSaveError(cachePath, err);
494
666
  return false;
495
667
  }
496
668
  }
497
669
  function clearCachedMessageCountsByPrefix(prefix, sep) {
498
670
  if (!prefix) {
499
- cachedMessageCounts.clear();
671
+ cachedPrefixes.clear();
500
672
  return;
501
673
  }
502
- for (const key of cachedMessageCounts.keys()) {
674
+ for (const key of cachedPrefixes.keys()) {
503
675
  if (key === prefix) {
504
- cachedMessageCounts.delete(key);
676
+ cachedPrefixes.delete(key);
505
677
  continue;
506
678
  }
507
679
  if (!key.startsWith(prefix + sep))
508
680
  continue;
509
- cachedMessageCounts.delete(key);
681
+ cachedPrefixes.delete(key);
510
682
  }
511
683
  }
512
- function clearInitializedCachesByScope(scope) {
513
- if (scope.cacheKey === undefined && scope.modelId === undefined) {
684
+ function clearInitializedCachesByPrefix(prefix, sep) {
685
+ if (!prefix) {
514
686
  initializedCaches.clear();
515
687
  return;
516
688
  }
517
689
  for (const key of initializedCaches) {
518
- const firstSep = key.indexOf(':');
519
- const secondSep = key.indexOf(':', firstSep + 1);
520
- if (firstSep === -1 || secondSep === -1)
521
- continue;
522
- const entryModelId = key.slice(0, firstSep);
523
- const entryCacheKey = key.slice(secondSep + 1);
524
- if (scope.cacheKey !== undefined && entryCacheKey !== scope.cacheKey) {
525
- continue;
526
- }
527
- if (scope.modelId !== undefined && entryModelId !== scope.modelId) {
528
- continue;
529
- }
530
- initializedCaches.delete(key);
690
+ if (key === prefix || key.startsWith(prefix + sep))
691
+ initializedCaches.delete(key);
531
692
  }
532
693
  }
533
694
  // ----- test-only escape hatches -----
@@ -542,16 +703,19 @@ function clearInitializedCachesByScope(scope) {
542
703
  */
543
704
  export const __kvCacheSessionTestHooks = {
544
705
  getSavedCount(cachePath) {
545
- return cachedMessageCounts.get(cachePath);
706
+ return cachedPrefixes.get(cachePath)?.messages;
546
707
  },
547
708
  setSavedCountForTest(cachePath, count) {
548
- cachedMessageCounts.set(cachePath, count);
709
+ cachedPrefixes.set(cachePath, { messages: count, toolBlock: false });
710
+ },
711
+ getToolBlockCachedForTest(cachePath) {
712
+ return cachedPrefixes.get(cachePath)?.toolBlock ?? false;
549
713
  },
550
- hasInitializedKey(modelId, configHash, cacheKey) {
551
- return initializedCaches.has(initRegistryKey(modelId, configHash, cacheKey));
714
+ hasInitializedPath(cachePath) {
715
+ return initializedCaches.has(cachePath);
552
716
  },
553
- markInitializedForTest(modelId, configHash, cacheKey) {
554
- initializedCaches.add(initRegistryKey(modelId, configHash, cacheKey));
717
+ getActivePathCountForTest(cachePath) {
718
+ return activeCachePaths.get(cachePath) ?? 0;
555
719
  },
556
720
  getLastAutoCacheSweepMsForTest() {
557
721
  return lastAutoCacheSweepMs;
@@ -560,12 +724,13 @@ export const __kvCacheSessionTestHooks = {
560
724
  lastAutoCacheSweepMs = value;
561
725
  },
562
726
  resetForTest() {
563
- cachedMessageCounts.clear();
727
+ cachedPrefixes.clear();
564
728
  initializedCaches.clear();
565
729
  activeCachePaths.clear();
566
730
  lastAutoCacheSweepMs = 0;
567
731
  autoCacheSweepInFlight = null;
568
732
  cacheStateLockTail = Promise.resolve();
733
+ cachePathLocks.clear();
569
734
  },
570
735
  waitForAutoCacheSweepForTest() {
571
736
  return autoCacheSweepInFlight ?? Promise.resolve();