pi-cache-optimizer 2.9.2 → 2.9.3

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/index.ts CHANGED
@@ -630,6 +630,12 @@ export default function (pi: ExtensionAPI) {
630
630
  * own shard was absorbed anyway (see adoptAbsorbedOwnShard).
631
631
  */
632
632
  let persistedOwnShards: PersistedStatsShardV7[] = [];
633
+ /**
634
+ * Which lifetime of the in-memory counters a built shard snapshots: bumped
635
+ * whenever session_start clears them (see adoptAbsorbedOwnShard).
636
+ */
637
+ let countersSession = 0;
638
+ const countersSessionOfShard = new WeakMap<PersistedStatsShardV7, number>();
633
639
  function recordPersistedOwnShard(shard: PersistedStatsShardV7): void {
634
640
  if (shard.instanceId !== instanceId) return;
635
641
  const last = persistedOwnShards.at(-1);
@@ -653,9 +659,17 @@ export default function (pi: ExtensionAPI) {
653
659
  // Not identifiable (clock skew between the processes): assume the
654
660
  // archive holds everything, so nothing is ever counted twice.
655
661
  const absorbed = candidates.at(-1) ?? buildCurrentStatsShard();
662
+ // Session guard: subtract only a version built from the counters held
663
+ // now. session_start clears them (switching to another session, or the
664
+ // same one on reload) but keeps the id, so a version persisted before it
665
+ // counts usage these counters no longer hold: subtracting it would hide
666
+ // this session's usage. Its sessionHash alone cannot tell: a reload keeps
667
+ // it, and it changes without a reset when the session id first becomes
668
+ // known after usage (then the counters still hold that usage).
669
+ const sameSession = countersSessionOfShard.get(absorbed) === countersSession;
656
670
  const remaining: Record<string, CacheStats> = {};
657
671
  for (const [key, stats] of Object.entries(cacheStatsProcessByModel)) {
658
- const entry = absorbed.globalEpoch === currentGlobalEpoch ? absorbed.models[key] : undefined;
672
+ const entry = sameSession && absorbed.globalEpoch === currentGlobalEpoch ? absorbed.models[key] : undefined;
659
673
  const sameEpoch = entry && entry.modelEpoch === (modelEpochByKey.get(key) ?? initialEpoch(`model:${key}`));
660
674
  const delta = cloneCacheStats(stats);
661
675
  if (sameEpoch) {
@@ -773,7 +787,7 @@ export default function (pi: ExtensionAPI) {
773
787
  stats: cloneCacheStats(stats),
774
788
  };
775
789
  }
776
- return {
790
+ const shard: PersistedStatsShardV7 = {
777
791
  version: 7,
778
792
  kind: "pi-cache-optimizer-shard",
779
793
  instanceId,
@@ -790,6 +804,8 @@ export default function (pi: ExtensionAPI) {
790
804
  models,
791
805
  ...(lastActualRoutedModel ? { lastRoutedModel: { ...lastActualRoutedModel } } : {}),
792
806
  };
807
+ countersSessionOfShard.set(shard, countersSession);
808
+ return shard;
793
809
  }
794
810
 
795
811
  function scheduleShardRefresh(ctx?: ExtensionContext, model?: PiModel): void {
@@ -967,6 +983,7 @@ export default function (pi: ExtensionAPI) {
967
983
  syncSessionHash(ctx);
968
984
  lastStatusText = undefined;
969
985
  cacheStatsProcessByModel = {};
986
+ countersSession++;
970
987
  cacheStatsLegacyFamily = emptyAllCacheStats();
971
988
  modelEpochByKey.clear();
972
989
  modelApiByKey.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.9.2",
3
+ "version": "2.9.3",
4
4
  "description": "Improve Pi prompt/KV cache hit rates with stable prompts, OpenAI-compatible cache keys, proxy compat warnings, and footer cache stats.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -185,8 +185,18 @@ export type StatsArchiveV2Document = {
185
185
 
186
186
  const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
187
187
  const GENERATION_FILE_PATTERN = /^archive\.(\d{1,15})\.json$/;
188
- /** 2.9.0's archive.json while it is being migrated (see compactStatsShardsV7). */
188
+ /**
189
+ * 2.9.0's archive.json while it is being migrated (see compactStatsShardsV7):
190
+ * `archive.migrating-<moved-at ms, 13 digits>-<uuid>.json`, or
191
+ * `archive.migrating-<uuid>.json` as 2.9.2 named it. Both match this pattern
192
+ * (2.9.2 reads both).
193
+ */
189
194
  const MIGRATION_PLACEHOLDER_PATTERN = /^archive\.migrating-[0-9a-f-]+\.json$/;
195
+ const MIGRATION_PLACEHOLDER_MOVED_AT_PATTERN = /^archive\.migrating-(\d{13})-[0-9a-f]{8}-[0-9a-f-]+\.json$/;
196
+
197
+ function migrationPlaceholderPath(archivePath: string, movedAt = Date.now()): string {
198
+ return join(dirname(archivePath), `archive.migrating-${String(Math.max(0, Math.floor(movedAt))).padStart(13, "0")}-${randomUUID()}.json`);
199
+ }
190
200
 
191
201
  export function emptyStatsArchive(now = Date.now()): StatsArchiveV1 {
192
202
  return { version: 1, kind: "pi-cache-optimizer-archive", updatedAt: now, absorbed: {}, buckets: [], routed: [] };
@@ -792,6 +802,14 @@ export type StatsArchiveTestHooks = {
792
802
  link?: (existingPath: string, newPath: string) => Promise<void>;
793
803
  /** Runs right after the no-hard-link fallback created a generation file exclusively, before writing it. */
794
804
  afterExclusiveCreate?: (target: string) => Promise<void>;
805
+ /**
806
+ * Runs between the steps of moving 2.9.0's archive.json aside (to land a
807
+ * concurrent 2.9.0 write there): "moved-aside" right after archive.json was
808
+ * renamed to its placeholder, before the tombstone directory is made;
809
+ * "undo-removed-tombstone" when undoing, right after the tombstone was
810
+ * removed, before the placeholder is put back.
811
+ */
812
+ afterMigrationStep?: (step: "moved-aside" | "undo-removed-tombstone", archivePath: string, placeholder: string) => Promise<void>;
795
813
  /** Overrides the maintenance heartbeat interval. */
796
814
  heartbeatMs?: number;
797
815
  };
@@ -820,6 +838,33 @@ async function listArchiveDirectory(archivePath: string): Promise<ArchiveListing
820
838
  return { generations: generations.sort((a, b) => a - b), placeholders: placeholders.sort() };
821
839
  }
822
840
 
841
+ /**
842
+ * The newest of several migration placeholders, chosen deterministically.
843
+ * Two exist only after a migration kept one for inspection (a 2.9.0 write
844
+ * landed between its move and its mkdir) and a later one was interrupted
845
+ * after its own move: the later one holds what 2.9.0 wrote after the first
846
+ * was moved, so it is the one to read and migrate from (the older one is set
847
+ * aside once a generation exists). Ordered by the time the file was moved
848
+ * aside (in its name), or for 2.9.2's uuid-only names by its mtime (rename
849
+ * keeps it; content 2.9.0 wrote after an earlier move is always newer than
850
+ * that move), then by mtime, then by name. A file that disappeared sorts
851
+ * first; the caller re-lists when it is gone.
852
+ */
853
+ async function newestMigrationPlaceholder(placeholders: readonly string[]): Promise<string | undefined> {
854
+ if (placeholders.length <= 1) return placeholders[0];
855
+ const keyed = await Promise.all(placeholders.map(async (path) => {
856
+ const info = await lstat(path).catch((error) => {
857
+ if (getErrorCode(error) === "ENOENT") return undefined;
858
+ throw error;
859
+ });
860
+ const mtime = info?.mtimeMs ?? -Infinity;
861
+ const moved = MIGRATION_PLACEHOLDER_MOVED_AT_PATTERN.exec(path.slice(dirname(path).length + 1));
862
+ return { path, time: info ? (moved ? Number(moved[1]) : mtime) : -Infinity, mtime };
863
+ }));
864
+ keyed.sort((a, b) => a.time - b.time || a.mtime - b.mtime || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
865
+ return keyed.at(-1)!.path;
866
+ }
867
+
823
868
  const SOURCE_ATTEMPTS = 6;
824
869
  /** Retries (with these delays) of a transient lstat/read failure before a reader gives up on this refresh. */
825
870
  const TRANSIENT_RETRY_DELAYS_MS = [15, 60, 150];
@@ -883,7 +928,13 @@ function parseArchiveBytes(kind: "legacy" | "generation", generation: number, pa
883
928
 
884
929
  class ArchiveRestart extends Error {}
885
930
 
886
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
931
+ /**
932
+ * A retry delay. Deliberately not unref'd: the read is awaited by a Pi
933
+ * handler (agent_settled, model_select, session_shutdown...), and in print
934
+ * mode an unref'd delay lets the event loop drain mid-handler, so Pi exits
935
+ * without session_shutdown (see renameWithRetry).
936
+ */
937
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
887
938
 
888
939
  export type ReadStatsArchiveOptions = {
889
940
  /** Throw on a transient I/O failure instead of serving the last good snapshot (compaction). */
@@ -1005,7 +1056,7 @@ async function locateAndReadArchive(archivePath: string, options: ReadStatsArchi
1005
1056
  return legacyCorruptView(archivePath, parsed.parsed.sha256);
1006
1057
  }
1007
1058
  if (info && !info.isDirectory()) return legacyCorruptView(archivePath, undefined);
1008
- const placeholder = listing.placeholders.at(-1);
1059
+ const placeholder = await newestMigrationPlaceholder(listing.placeholders);
1009
1060
  if (placeholder) {
1010
1061
  const parsed = await readArchiveFile(placeholder, "legacy", 0, options, restartIfNewer);
1011
1062
  if (!parsed) throw new ArchiveRestart();
@@ -1349,12 +1400,13 @@ async function moveLegacyArchiveAside(archivePath: string, base: StatsArchiveVie
1349
1400
  const expected = base.legacySourceSha256;
1350
1401
  let placeholder = source;
1351
1402
  if (source === archivePath) {
1352
- placeholder = join(dirname(archivePath), `archive.migrating-${randomUUID()}.json`);
1403
+ placeholder = migrationPlaceholderPath(archivePath);
1353
1404
  try {
1354
1405
  await renameWithRetry(archivePath, placeholder, LEGACY_ARCHIVE_RENAME_BUDGET_MS);
1355
1406
  } catch {
1356
1407
  return false;
1357
1408
  }
1409
+ await testHooks.afterMigrationStep?.("moved-aside", archivePath, placeholder);
1358
1410
  }
1359
1411
  try {
1360
1412
  await createLegacyTombstone(archivePath);
@@ -1374,13 +1426,38 @@ async function moveLegacyArchiveAside(archivePath: string, base: StatsArchiveVie
1374
1426
  // 2.9.0 republished between this pass's read and the move: undo, so the
1375
1427
  // next pass migrates the newer file.
1376
1428
  await rm(archivePath, { recursive: true, force: true }).catch(() => undefined);
1377
- await renameWithRetry(placeholder, archivePath, LEGACY_ARCHIVE_RENAME_BUDGET_MS).catch(() => undefined);
1429
+ await testHooks.afterMigrationStep?.("undo-removed-tombstone", archivePath, placeholder);
1430
+ await restoreLegacyArchive(placeholder, archivePath);
1378
1431
  return false;
1379
1432
  }
1380
1433
  await fsyncDirectory(dirname(archivePath));
1381
1434
  return true;
1382
1435
  }
1383
1436
 
1437
+ /**
1438
+ * Undo a move aside: put the placeholder back as archive.json without ever
1439
+ * replacing a file there. Between removing the tombstone and this step a
1440
+ * 2.9.0 process (holding the lock after a stale takeover) may already have
1441
+ * published archive.json again; a rename would silently overwrite it, losing
1442
+ * what it absorbed (its raw shards are already deleted). link() is a
1443
+ * compare-and-swap: EEXIST keeps 2.9.0's newer file as the archive and keeps
1444
+ * the placeholder for inspection (set aside once a generation exists, never
1445
+ * counted, so nothing is counted twice). Without hard links the rename is
1446
+ * the only way back (the window stays open on such filesystems).
1447
+ */
1448
+ async function restoreLegacyArchive(placeholder: string, archivePath: string): Promise<void> {
1449
+ try {
1450
+ await (testHooks.link ?? link)(placeholder, archivePath);
1451
+ } catch (error) {
1452
+ if (getErrorCode(error) === "EEXIST") return;
1453
+ await renameWithRetry(placeholder, archivePath, LEGACY_ARCHIVE_RENAME_BUDGET_MS).catch(() => undefined);
1454
+ return;
1455
+ }
1456
+ // Both names are the same file now; drop the placeholder. If that fails it
1457
+ // is resolved later (its bytes are the ones the next migration reads).
1458
+ await unlink(placeholder).catch(() => undefined);
1459
+ }
1460
+
1384
1461
  /** Instance ids of every shard-named file in `directory`, readable or not. */
1385
1462
  async function listShardInstanceIds(directory: string): Promise<Set<string>> {
1386
1463
  let names: string[];
@@ -891,7 +891,13 @@ export async function renameWithRetry(from: string, to: string, budgetMs = RENAM
891
891
  const remaining = deadline - Date.now();
892
892
  if (remaining <= 0 || !RENAME_RETRY_CODES.has(getErrorCode(error) ?? "")) throw error;
893
893
  const delay = Math.min(20 * 2 ** attempt, RENAME_RETRY_MAX_DELAY_MS, remaining);
894
- await new Promise((resolve) => setTimeout(resolve, delay));
894
+ // Deliberately not unref'd: every caller awaits this rename (a Pi
895
+ // handler, or the restore that session_shutdown awaits), and Pi's
896
+ // print mode exits by letting the event loop drain. An unref'd delay
897
+ // would let the process exit in the middle of an awaited handler,
898
+ // skipping session_shutdown and its final flush. The delay is bounded
899
+ // by budgetMs, and interactive mode exits with process.exit anyway.
900
+ await new Promise<void>((resolve) => setTimeout(resolve, delay));
895
901
  }
896
902
  }
897
903
  }