mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
fix(installer): protect live workers and orphan-aware retention in cache prune
Address the review findings on the cache prune: - Repair reaches copyPluginToCache without stopping the worker, so the prune now runs through prunePluginCacheSafely: it protects whatever version a live worker reports, and retains every version when a worker is present but its version cannot be read. copyPluginToCache is now async. - The prune command validates its whole argument list: unknown flags (a typo like --dry-rnu) and a non-integer --keep abort before anything is removed, instead of falling through to a destructive run. - Retention counts only usable (non-orphaned) versions, so an orphaned newest directory can no longer displace a usable rollback version. The planner excludes .orphaned_at directories from the keep budget and prunes them. probeLiveWorker classifies the health result (version / reachable-unknown / unreachable) so an intentionally stopped worker (connection refused) still prunes, while a hung or malformed worker retains all. Generated-By: PostHog Desktop Task-Id: 50a659f2-3786-417b-983d-ce8fa9857bec
This commit is contained in:
@@ -177,7 +177,7 @@ import {
|
||||
readPluginVersion,
|
||||
writeJsonFileAtomic,
|
||||
} from '../utils/paths.js';
|
||||
import { prunePluginCache } from '../utils/prune-cache.js';
|
||||
import { prunePluginCacheSafely } from '../utils/prune-cache.js';
|
||||
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||
import { readFlatSettings } from '../utils/settings.js';
|
||||
import { shutdownWorkerAndWait } from '../../services/install/shutdown-helper.js';
|
||||
@@ -746,7 +746,7 @@ export function writeTrimmedMarketplacePackageJson(packageRoot: string, marketpl
|
||||
writeFileSync(join(marketplaceDir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function copyPluginToCache(version: string): void {
|
||||
async function copyPluginToCache(version: string): Promise<void> {
|
||||
const sourcePluginDirectory = npmPackagePluginDirectory();
|
||||
const cachePath = pluginCacheDirectory(version);
|
||||
|
||||
@@ -756,11 +756,14 @@ function copyPluginToCache(version: string): void {
|
||||
|
||||
// Prune superseded versions now that the new one has landed. Without this the
|
||||
// cache grew one directory per release forever, and every retained directory
|
||||
// stayed a runnable old-version worker source (#4105). Keep the just-written
|
||||
// version plus N-1; the caller has already stopped the worker, so nothing is
|
||||
// running from a directory we might remove.
|
||||
const pruned = prunePluginCache({ protectedVersions: [version] });
|
||||
if (pruned.removed.length > 0) {
|
||||
// stayed a runnable old-version worker source (#4105). The safe prune keeps
|
||||
// the just-written version plus N-1 and protects any live worker's version —
|
||||
// the repair path reaches here without stopping the worker, so a running
|
||||
// older worker must never lose its source directory.
|
||||
const pruned = await prunePluginCacheSafely({ additionalProtectedVersions: [version] });
|
||||
if (pruned.retainedForLiveWorker) {
|
||||
log.info('Skipped cache prune: a worker is running but its version could not be read; retaining all versions.');
|
||||
} else if (pruned.removed.length > 0) {
|
||||
log.info(`Pruned ${pruned.removed.length} stale plugin cache version(s): ${pruned.removed.join(', ')}`);
|
||||
}
|
||||
for (const failure of pruned.failed) {
|
||||
@@ -2093,7 +2096,7 @@ async function runInstallCommandInner(options: InstallOptions, summary: InstallS
|
||||
title: 'Caching plugin version',
|
||||
task: async (message) => {
|
||||
message(`Caching v${version}...`);
|
||||
copyPluginToCache(version);
|
||||
await copyPluginToCache(version);
|
||||
return `Plugin cached (v${version}) ${styleText('green', 'OK')}`;
|
||||
},
|
||||
},
|
||||
@@ -2500,7 +2503,7 @@ async function runRepairCommandInner(summary: InstallSummary): Promise<void> {
|
||||
// fail immediately with no package.json to install against.
|
||||
if (!existsSync(join(cacheDir, 'package.json'))) {
|
||||
message('Cache missing — repopulating from npm package…');
|
||||
copyPluginToCache(version);
|
||||
await copyPluginToCache(version);
|
||||
}
|
||||
message('Reinstalling plugin dependencies…');
|
||||
const { bunPath } = bun;
|
||||
|
||||
@@ -5,55 +5,91 @@
|
||||
* worker act on the shared database (#4105). The installer prunes on every
|
||||
* install; this command exposes the same routine on demand, beside `doctor`.
|
||||
*
|
||||
* Keeps the newest two versions and whatever version the live worker reports,
|
||||
* so it never removes the directory a running worker was launched from.
|
||||
* `--dry-run` reports the plan without deleting. `--keep <n>` overrides how many
|
||||
* newest versions to retain.
|
||||
* Keeps the newest two usable versions and whatever version a live worker
|
||||
* reports, so it never removes the directory a running worker was launched
|
||||
* from. When a worker is present but its version cannot be read, every version
|
||||
* is retained. `--dry-run` reports the plan without deleting. `--keep <n>`
|
||||
* overrides how many newest usable versions to retain.
|
||||
*/
|
||||
|
||||
import { styleText } from 'node:util';
|
||||
import { pluginCacheRootDirectory } from '../utils/paths.js';
|
||||
import {
|
||||
DEFAULT_CACHE_RETENTION,
|
||||
fetchLiveWorkerVersion,
|
||||
planCachePrune,
|
||||
prunePluginCache,
|
||||
readCacheVersionDirectories,
|
||||
planPluginCachePrune,
|
||||
prunePluginCacheSafely,
|
||||
resolveWorkerProtection,
|
||||
} from '../utils/prune-cache.js';
|
||||
|
||||
function parseKeepCount(argv: string[]): number {
|
||||
const index = argv.indexOf('--keep');
|
||||
if (index === -1) return DEFAULT_CACHE_RETENTION;
|
||||
const raw = argv[index + 1];
|
||||
const parsed = Number.parseInt(raw ?? '', 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
console.error(styleText('red', `Invalid --keep value: ${raw ?? '(missing)'}. Use a positive integer.`));
|
||||
process.exit(1);
|
||||
const USAGE = 'Usage: npx claude-mem prune [--dry-run] [--keep <n>]';
|
||||
|
||||
interface PruneArgs {
|
||||
dryRun: boolean;
|
||||
keepCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the argument list strictly: reject any unknown flag and require `--keep`
|
||||
* to be a whole positive integer. A permissive parser turned a typo like
|
||||
* `--dry-rnu` into a real deletion and let `--keep 2junk` through as 2 (#4105
|
||||
* review), so an unrecognized argument must abort before anything is removed.
|
||||
*/
|
||||
function parseArgs(argv: string[]): PruneArgs {
|
||||
let dryRun = false;
|
||||
let keepCount = DEFAULT_CACHE_RETENTION;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') {
|
||||
dryRun = true;
|
||||
} else if (arg === '--keep') {
|
||||
const raw = argv[++i];
|
||||
if (raw === undefined || !/^\d+$/.test(raw) || Number.parseInt(raw, 10) < 1) {
|
||||
console.error(styleText('red', `Invalid --keep value: ${raw ?? '(missing)'}. Use a whole positive integer.`));
|
||||
console.error(USAGE);
|
||||
process.exit(1);
|
||||
}
|
||||
keepCount = Number.parseInt(raw, 10);
|
||||
} else {
|
||||
console.error(styleText('red', `Unknown argument: ${arg}`));
|
||||
console.error(USAGE);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
|
||||
return { dryRun, keepCount };
|
||||
}
|
||||
|
||||
export async function runPruneCommand(argv: string[] = []): Promise<void> {
|
||||
const dryRun = argv.includes('--dry-run');
|
||||
const keepCount = parseKeepCount(argv);
|
||||
const { dryRun, keepCount } = parseArgs(argv);
|
||||
const root = pluginCacheRootDirectory();
|
||||
|
||||
const liveVersion = await fetchLiveWorkerVersion();
|
||||
const protectedVersions = liveVersion ? [liveVersion] : [];
|
||||
|
||||
console.log(styleText('bold', '\nclaude-mem prune\n'));
|
||||
console.log(` ${styleText('dim', 'Cache root:')} ${root}`);
|
||||
console.log(` ${styleText('dim', 'Keeping:')} newest ${keepCount}${liveVersion ? ` + live worker v${liveVersion}` : ''}`);
|
||||
console.log(` ${styleText('dim', 'Keeping:')} newest ${keepCount} usable version(s)`);
|
||||
|
||||
if (dryRun) {
|
||||
const { keep, prune } = planCachePrune(readCacheVersionDirectories(root), keepCount, protectedVersions);
|
||||
const { protectedVersions, retainAll } = await resolveWorkerProtection();
|
||||
if (retainAll) {
|
||||
console.log(` ${styleText('yellow', 'Would remove:')} (none — a worker is running but its version could not be read)`);
|
||||
console.log(styleText('dim', '\nDry run — nothing was deleted.'));
|
||||
return;
|
||||
}
|
||||
const { keep, prune } = planPluginCachePrune(root, keepCount, protectedVersions);
|
||||
if (protectedVersions.length > 0) {
|
||||
console.log(` ${styleText('dim', 'Live worker:')} protecting v${protectedVersions.join(', ')}`);
|
||||
}
|
||||
console.log(` ${styleText('dim', 'Keep:')} ${keep.length > 0 ? keep.join(', ') : '(none)'}`);
|
||||
console.log(` ${styleText('yellow', 'Would remove:')} ${prune.length > 0 ? prune.join(', ') : '(none)'}`);
|
||||
console.log(styleText('dim', '\nDry run — nothing was deleted.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = prunePluginCache({ cacheRoot: root, keepCount, protectedVersions });
|
||||
const result = await prunePluginCacheSafely({ cacheRoot: root, keepCount });
|
||||
if (result.retainedForLiveWorker) {
|
||||
console.log(styleText('yellow', '\nA worker is running but its version could not be read — retained all versions.'));
|
||||
return;
|
||||
}
|
||||
console.log(` ${styleText('dim', 'Kept:')} ${result.kept.length > 0 ? result.kept.join(', ') : '(none)'}`);
|
||||
if (result.removed.length > 0) {
|
||||
console.log(` ${styleText('green', 'Removed:')} ${result.removed.join(', ')}`);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { existsSync, readdirSync, rmSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { pluginCacheRootDirectory } from './paths.js';
|
||||
import { compareVersionsDescending, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { readOwnedWorkerPidInfo } from '../../supervisor/index.js';
|
||||
import { verifyPidFileOwnership } from '../../supervisor/process-registry.js';
|
||||
|
||||
/**
|
||||
* Versions of the plugin cache to keep: the newly installed one (N) and the
|
||||
@@ -17,50 +19,63 @@ function isVersionDirectoryName(name: string): boolean {
|
||||
return /^\d/.test(name);
|
||||
}
|
||||
|
||||
/** True when a cache version directory carries Claude Code's `.orphaned_at`
|
||||
* marker. The worker-script resolver skips these, so they are never a live
|
||||
* install and must not count toward the retention budget. */
|
||||
function isOrphanedVersion(root: string, name: string): boolean {
|
||||
return existsSync(join(root, name, '.orphaned_at'));
|
||||
}
|
||||
|
||||
export interface CachePrunePlan {
|
||||
/** Versions retained: the newest `keepCount`, plus any protected version. */
|
||||
/** Versions retained: the newest usable `keepCount`, plus any protected version. */
|
||||
keep: string[];
|
||||
/** Versions to delete, newest-first. */
|
||||
prune: string[];
|
||||
}
|
||||
|
||||
export interface PlanCachePruneOptions {
|
||||
/** Versions never pruned (for example the live worker's version). */
|
||||
protectedVersions?: Iterable<string>;
|
||||
/** Versions marked `.orphaned_at`: never counted toward retention, always
|
||||
* prunable unless also protected. */
|
||||
orphanedVersions?: Iterable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which cache versions to keep and which to prune. Pure: no disk access.
|
||||
*
|
||||
* Keeps the newest `keepCount` versions by the shared descending order, plus
|
||||
* every version in `protectedVersions` (the live worker's version, so a prune
|
||||
* never removes the directory a running worker was launched from). Names that
|
||||
* are not version directories are ignored — never pruned.
|
||||
* Retention counts only usable (non-orphaned) versions, so an orphaned newest
|
||||
* directory never displaces a usable rollback version (#4105 review). Keeps the
|
||||
* newest `keepCount` usable versions plus every protected version; prunes the
|
||||
* rest, including orphaned directories that are not protected.
|
||||
*/
|
||||
export function planCachePrune(
|
||||
versionDirectoryNames: string[],
|
||||
keepCount: number,
|
||||
protectedVersions: Iterable<string> = [],
|
||||
options: PlanCachePruneOptions = {},
|
||||
): CachePrunePlan {
|
||||
const ordered = versionDirectoryNames
|
||||
const protectedSet = new Set(options.protectedVersions);
|
||||
const orphanedSet = new Set(options.orphanedVersions);
|
||||
|
||||
const versions = versionDirectoryNames
|
||||
.filter(isVersionDirectoryName)
|
||||
.sort(compareVersionsDescending);
|
||||
const protectedSet = new Set(protectedVersions);
|
||||
|
||||
const usable = versions.filter(version => !orphanedSet.has(version));
|
||||
const retained = new Set(usable.slice(0, keepCount));
|
||||
|
||||
const keep: string[] = [];
|
||||
const prune: string[] = [];
|
||||
ordered.forEach((version, index) => {
|
||||
if (index < keepCount || protectedSet.has(version)) {
|
||||
for (const version of versions) {
|
||||
if (retained.has(version) || protectedSet.has(version)) {
|
||||
keep.push(version);
|
||||
} else {
|
||||
prune.push(version);
|
||||
}
|
||||
});
|
||||
}
|
||||
return { keep, prune };
|
||||
}
|
||||
|
||||
export interface CachePruneResult {
|
||||
root: string;
|
||||
kept: string[];
|
||||
removed: string[];
|
||||
failed: { version: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** List the version-directory names under a cache root, best-effort. Returns
|
||||
* an empty list when the root is absent or unreadable. */
|
||||
export function readCacheVersionDirectories(root: string): string[] {
|
||||
@@ -77,24 +92,50 @@ export function readCacheVersionDirectories(root: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CachePruneResult {
|
||||
root: string;
|
||||
kept: string[];
|
||||
removed: string[];
|
||||
failed: { version: string; reason: string }[];
|
||||
/** True when pruning was skipped because a live worker's version could not be
|
||||
* determined, so every version was retained for safety. */
|
||||
retainedForLiveWorker: boolean;
|
||||
}
|
||||
|
||||
export interface PrunePluginCacheOptions {
|
||||
cacheRoot?: string;
|
||||
keepCount?: number;
|
||||
protectedVersions?: Iterable<string>;
|
||||
}
|
||||
|
||||
/** Read the version names and their `.orphaned_at` state, then plan the prune.
|
||||
* Read-only — use for a dry-run preview that matches what `prunePluginCache`
|
||||
* would delete. */
|
||||
export function planPluginCachePrune(
|
||||
root: string,
|
||||
keepCount: number,
|
||||
protectedVersions: Iterable<string> = [],
|
||||
): CachePrunePlan {
|
||||
const names = readCacheVersionDirectories(root);
|
||||
const orphanedVersions = names.filter(name => isVersionDirectoryName(name) && isOrphanedVersion(root, name));
|
||||
return planCachePrune(names, keepCount, { protectedVersions, orphanedVersions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove superseded plugin cache versions, keeping the newest `keepCount` and
|
||||
* any protected version. Best-effort: a directory that cannot be removed (for
|
||||
* example a live worker's files locked on Windows) is reported in `failed`
|
||||
* Remove superseded plugin cache versions, keeping the newest usable `keepCount`
|
||||
* and any protected version. Best-effort: a directory that cannot be removed
|
||||
* (for example a live worker's files locked on Windows) is reported in `failed`
|
||||
* rather than aborting the caller.
|
||||
*
|
||||
* This does not itself protect a running worker — callers that may run while a
|
||||
* worker is live must pass the live version in `protectedVersions`, or use
|
||||
* `prunePluginCacheSafely`.
|
||||
*/
|
||||
export function prunePluginCache(options: PrunePluginCacheOptions = {}): CachePruneResult {
|
||||
const root = options.cacheRoot ?? pluginCacheRootDirectory();
|
||||
const keepCount = options.keepCount ?? DEFAULT_CACHE_RETENTION;
|
||||
|
||||
const names = readCacheVersionDirectories(root);
|
||||
const { keep, prune } = planCachePrune(names, keepCount, options.protectedVersions);
|
||||
const { keep, prune } = planPluginCachePrune(root, keepCount, options.protectedVersions ?? []);
|
||||
const removed: string[] = [];
|
||||
const failed: { version: string; reason: string }[] = [];
|
||||
for (const version of prune) {
|
||||
@@ -105,36 +146,103 @@ export function prunePluginCache(options: PrunePluginCacheOptions = {}): CachePr
|
||||
failed.push({ version, reason: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
return { root, kept: keep, removed, failed };
|
||||
return { root, kept: keep, removed, failed, retainedForLiveWorker: false };
|
||||
}
|
||||
|
||||
export type LiveWorkerProbe =
|
||||
/** The worker answered with a usable version. */
|
||||
| { status: 'version'; version: string }
|
||||
/** A worker is present but its version cannot be read (timed out, or a
|
||||
* malformed body). Pruning must retain every version. */
|
||||
| { status: 'reachable-unknown' }
|
||||
/** Nothing is answering on the worker port. */
|
||||
| { status: 'unreachable' };
|
||||
|
||||
/**
|
||||
* Best-effort read of the version the live worker self-reports on
|
||||
* GET /api/health. Returns null when no worker is reachable, so a prune run
|
||||
* with no worker up simply falls back to the keep-newest rule.
|
||||
* Probe the worker's health endpoint and classify the result. A usable version
|
||||
* lets a prune protect exactly that directory; a present-but-unreadable worker
|
||||
* forces a full retention; an unreachable port leaves protection to the PID
|
||||
* check in `resolveWorkerProtection`.
|
||||
*/
|
||||
export async function fetchLiveWorkerVersion(): Promise<string | null> {
|
||||
export async function probeLiveWorker(): Promise<LiveWorkerProbe> {
|
||||
let response: Response;
|
||||
try {
|
||||
const response = await workerHttpRequest('/api/health', { timeoutMs: 2000 });
|
||||
const body = await response.json() as { version?: unknown };
|
||||
return typeof body.version === 'string' ? body.version : null;
|
||||
} catch {
|
||||
return null;
|
||||
response = await workerHttpRequest('/api/health', { timeoutMs: 2000 });
|
||||
} catch (error: unknown) {
|
||||
// fetchWithTimeout rethrows a timeout as an Error whose message contains
|
||||
// "timed out"; anything with that shape means a worker may be alive but
|
||||
// hung, so we must not prune its source. Every other failure (connection
|
||||
// refused, DNS) means nothing is listening.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /timed out/i.test(message) ? { status: 'reachable-unknown' } : { status: 'unreachable' };
|
||||
}
|
||||
|
||||
let body: { version?: unknown };
|
||||
try {
|
||||
body = await response.json() as { version?: unknown };
|
||||
} catch {
|
||||
return { status: 'reachable-unknown' };
|
||||
}
|
||||
if (typeof body.version === 'string' && body.version.length > 0) {
|
||||
return { status: 'version', version: body.version };
|
||||
}
|
||||
return { status: 'reachable-unknown' };
|
||||
}
|
||||
|
||||
export interface WorkerProtection {
|
||||
protectedVersions: string[];
|
||||
/** When true, no prune may delete anything: a worker is running but its
|
||||
* version is unknown, so we cannot tell which directory to keep. */
|
||||
retainAll: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune the cache while protecting whatever version the live worker reports, so
|
||||
* a standalone prune run can never pull the directory out from under a running
|
||||
* worker. Used by the `npx claude-mem prune` command.
|
||||
* Decide how a prune must protect a running worker. A readable version protects
|
||||
* exactly that directory. A present-but-unreadable worker (hung health, or a
|
||||
* live PID with a silent port) forces a full retention. Only when nothing is
|
||||
* running is a prune free to delete superseded versions.
|
||||
*/
|
||||
export async function prunePluginCacheProtectingLiveWorker(
|
||||
options: PrunePluginCacheOptions = {},
|
||||
): Promise<CachePruneResult> {
|
||||
const liveVersion = await fetchLiveWorkerVersion();
|
||||
const protectedVersions = [
|
||||
...(options.protectedVersions ?? []),
|
||||
...(liveVersion ? [liveVersion] : []),
|
||||
];
|
||||
return prunePluginCache({ ...options, protectedVersions });
|
||||
export async function resolveWorkerProtection(): Promise<WorkerProtection> {
|
||||
const probe = await probeLiveWorker();
|
||||
if (probe.status === 'version') {
|
||||
return { protectedVersions: [probe.version], retainAll: false };
|
||||
}
|
||||
if (probe.status === 'reachable-unknown') {
|
||||
return { protectedVersions: [], retainAll: true };
|
||||
}
|
||||
// Port silent — confirm no worker process is alive before deleting anything.
|
||||
const workerAlive = verifyPidFileOwnership(readOwnedWorkerPidInfo());
|
||||
return { protectedVersions: [], retainAll: workerAlive };
|
||||
}
|
||||
|
||||
export interface PrunePluginCacheSafelyOptions {
|
||||
cacheRoot?: string;
|
||||
keepCount?: number;
|
||||
/** Versions to protect in addition to the live worker's (for example the
|
||||
* version an installer just wrote). */
|
||||
additionalProtectedVersions?: Iterable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune the cache while protecting a running worker. When the worker's version
|
||||
* cannot be determined but a worker is present, every version is retained so a
|
||||
* prune can never pull the source directory out from under a live process. Used
|
||||
* by both the installer and the `npx claude-mem prune` command.
|
||||
*/
|
||||
export async function prunePluginCacheSafely(
|
||||
options: PrunePluginCacheSafelyOptions = {},
|
||||
): Promise<CachePruneResult> {
|
||||
const root = options.cacheRoot ?? pluginCacheRootDirectory();
|
||||
const { protectedVersions, retainAll } = await resolveWorkerProtection();
|
||||
|
||||
if (retainAll) {
|
||||
const kept = readCacheVersionDirectories(root).filter(isVersionDirectoryName);
|
||||
return { root, kept, removed: [], failed: [], retainedForLiveWorker: true };
|
||||
}
|
||||
|
||||
return prunePluginCache({
|
||||
cacheRoot: root,
|
||||
keepCount: options.keepCount,
|
||||
protectedVersions: [...(options.additionalProtectedVersions ?? []), ...protectedVersions],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
planCachePrune,
|
||||
planPluginCachePrune,
|
||||
prunePluginCache,
|
||||
DEFAULT_CACHE_RETENTION,
|
||||
} from '../../src/npx-cli/utils/prune-cache.js';
|
||||
@@ -22,7 +23,7 @@ describe('planCachePrune', () => {
|
||||
const { keep, prune } = planCachePrune(
|
||||
['13.25.1', '13.25.0', '13.20.0'],
|
||||
2,
|
||||
['13.20.0'],
|
||||
{ protectedVersions: ['13.20.0'] },
|
||||
);
|
||||
expect(keep).toContain('13.20.0');
|
||||
expect(prune).not.toContain('13.20.0');
|
||||
@@ -50,6 +51,18 @@ describe('planCachePrune', () => {
|
||||
const { prune } = planCachePrune(['13.25.1', '13.25.0'], 2);
|
||||
expect(prune).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not let an orphaned newest directory consume a retention slot', () => {
|
||||
// 13.26.0 is orphaned: the resolver ignores it, so it must not displace a
|
||||
// usable rollback version. Keep the two newest usable ones and prune the orphan.
|
||||
const { keep, prune } = planCachePrune(
|
||||
['13.26.0', '13.25.0', '13.24.0'],
|
||||
2,
|
||||
{ orphanedVersions: ['13.26.0'] },
|
||||
);
|
||||
expect(keep).toEqual(['13.25.0', '13.24.0']);
|
||||
expect(prune).toEqual(['13.26.0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prunePluginCache', () => {
|
||||
@@ -80,4 +93,42 @@ describe('prunePluginCache', () => {
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(result.kept).toEqual([]);
|
||||
});
|
||||
|
||||
it('prunes an orphaned newest directory and keeps usable rollback versions', () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'claude-mem-prune-orphan-'));
|
||||
for (const version of ['13.24.0', '13.25.0', '13.26.0']) {
|
||||
mkdirSync(join(root, version));
|
||||
}
|
||||
// Claude Code stamps the superseded newest directory as orphaned.
|
||||
writeFileSync(join(root, '13.26.0', '.orphaned_at'), '');
|
||||
|
||||
const result = prunePluginCache({ cacheRoot: root, keepCount: 2 });
|
||||
|
||||
expect(result.removed).toEqual(['13.26.0']);
|
||||
expect(existsSync(join(root, '13.26.0'))).toBe(false);
|
||||
expect(existsSync(join(root, '13.25.0'))).toBe(true);
|
||||
expect(existsSync(join(root, '13.24.0'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPluginCachePrune', () => {
|
||||
let root: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (root && existsSync(root)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads .orphaned_at markers from disk and previews the same removals', () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'claude-mem-prune-plan-'));
|
||||
for (const version of ['13.24.0', '13.25.0', '13.26.0']) {
|
||||
mkdirSync(join(root, version));
|
||||
}
|
||||
writeFileSync(join(root, '13.26.0', '.orphaned_at'), '');
|
||||
|
||||
const { keep, prune } = planPluginCachePrune(root, 2);
|
||||
expect(prune).toEqual(['13.26.0']);
|
||||
expect(keep).toEqual(['13.25.0', '13.24.0']);
|
||||
// Preview only — nothing deleted.
|
||||
expect(existsSync(join(root, '13.26.0'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user