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:
posthog[bot]
2026-09-17 12:12:52 +00:00
committed by GitHub
parent 29fbe416d8
commit 8393052ac1
4 changed files with 279 additions and 81 deletions
+12 -9
View File
@@ -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;
+61 -25
View File
@@ -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(', ')}`);
+153 -45
View File
@@ -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],
});
}
+53 -2
View File
@@ -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);
});
});