diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index 0b7d5026..ab92a58c 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -55,6 +55,7 @@ import { readBoundedResponseText } from './bounded-response' import { scopedReadToolNames } from '../../shared/scoped-data-tools' import { readBoundedFile } from '../workspace-file-access' import { terminateProcessTreeAndWait } from './child-process-termination' +import { CONTINUE_HOST_LAYOUT_VERSION } from './continue-host-layout' const supportedVersion = '1.5.47' const supportedBundleHashes = new Set([ @@ -1052,7 +1053,7 @@ export class ContinueHostAdapter { const digest = sourceHash.slice(0, 16) const targetRoot = join( this.options.cacheRoot, - `host-v7-${supportedVersion}-${digest}` + `host-v${CONTINUE_HOST_LAYOUT_VERSION}-${supportedVersion}-${digest}` ) const targetDist = join(targetRoot, 'dist') const targetBundle = join(targetDist, 'index.js') diff --git a/src/main/agent/continue-host-layout.ts b/src/main/agent/continue-host-layout.ts new file mode 100644 index 00000000..9a658d9d --- /dev/null +++ b/src/main/agent/continue-host-layout.ts @@ -0,0 +1,2 @@ +/** Prepared Continue host directories are `host-v-…`; older layouts are never read again. */ +export const CONTINUE_HOST_LAYOUT_VERSION = 7 diff --git a/src/main/index.ts b/src/main/index.ts index ccfa6b5c..db1c2ae5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,8 @@ import { import { createTrayIcon } from './tray-icon' import { resolveBundledRuntimePaths } from './agent/bundled-runtimes' import type { ContinueHostLauncher } from './agent/continue-host-adapter' +import { CONTINUE_HOST_LAYOUT_VERSION } from './agent/continue-host-layout' +import { removeStaleRuntimeCaches } from './stale-runtime-cache-cleanup' import { resolvePortableUserDataPath } from './portable-user-data' import { BrowserService } from './browser/browser-service' import { SubagentService } from './assistant/subagent-service' @@ -1626,6 +1628,14 @@ if (hasSingleInstanceLock) { startupMark('main:load-main-window') removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow, new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion())) + // Disk only: old versions' runtime caches, removed well after the first frame. + setTimeout(() => { + void removeStaleRuntimeCaches({ + userDataPath: app.getPath('userData'), + appVersion: app.getVersion(), + continueHostLayoutVersion: CONTINUE_HOST_LAYOUT_VERSION + }) + }, 30_000).unref() setImmediate(() => { void repairStaleWindowsNotificationShortcuts({ platform: process.platform, diff --git a/src/main/stale-runtime-cache-cleanup.test.ts b/src/main/stale-runtime-cache-cleanup.test.ts new file mode 100644 index 00000000..4ef9f2d3 --- /dev/null +++ b/src/main/stale-runtime-cache-cleanup.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeStaleRuntimeCaches, staleRuntimeCacheDirectories } from './stale-runtime-cache-cleanup' + +const directories: string[] = [] +afterEach(async () => { + for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }) +}) + +describe('stale runtime cache cleanup', () => { + it('selects only older app-version and Continue layout directories', () => { + expect(staleRuntimeCacheDirectories({ + opencode: ['0.12.8', '0.15.8', '0.15.9', '0.16.0-beta.1', 'cache', 'custom', '1.0'], + continueHost: ['host-v1-1.5.47-a', 'host-v6-1.5.47-a', 'host-v7-1.5.47-a', 'host-v7-1.6.0-b', + 'host-v8-1.5.47-a', 'isolated-global', 'isolated-global-123', 'host-v7-1.5.47-a.staging-x', 'sessions'] + }, { appVersion: '0.15.9', continueHostLayoutVersion: 7 })).toEqual({ + opencode: ['0.12.8', '0.15.8', '0.16.0-beta.1'], + continueHost: ['host-v1-1.5.47-a', 'host-v6-1.5.47-a'] + }) + }) + + it('removes stale directories and leaves everything else in the user data directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cache-cleanup-')) + directories.push(root) + const make = async (path: string): Promise => { + await mkdir(join(root, path), { recursive: true }) + await writeFile(join(root, path, 'file.txt'), 'x') + } + await make('opencode-runtime/0.15.8/config') + await make('opencode-runtime/0.15.9/config') + await make('continue-host/host-v6-1.5.47-a/dist') + await make('continue-host/host-v7-1.5.47-a/dist') + await make('continue-host/isolated-global') + await make('continue-host/sessions') + await writeFile(join(root, 'continue-host', 'config.yaml'), 'x') + await make('models/embedding') + + const { removed } = await removeStaleRuntimeCaches({ + userDataPath: root, appVersion: '0.15.9', continueHostLayoutVersion: 7 + }) + + expect(removed.map((path) => path.slice(root.length + 1).replaceAll('\\', '/')).sort()) + .toEqual(['continue-host/host-v6-1.5.47-a', 'opencode-runtime/0.15.8']) + for (const kept of ['opencode-runtime/0.15.9/config/file.txt', 'continue-host/host-v7-1.5.47-a/dist/file.txt', + 'continue-host/isolated-global/file.txt', 'continue-host/sessions/file.txt', 'continue-host/config.yaml', + 'models/embedding/file.txt']) { + expect(existsSync(join(root, kept))).toBe(true) + } + }) + + it('does nothing when the cache roots do not exist', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cache-cleanup-')) + directories.push(root) + await expect(removeStaleRuntimeCaches({ userDataPath: root, appVersion: '0.15.9', continueHostLayoutVersion: 7 })) + .resolves.toEqual({ removed: [] }) + }) +}) diff --git a/src/main/stale-runtime-cache-cleanup.ts b/src/main/stale-runtime-cache-cleanup.ts new file mode 100644 index 00000000..3f5ec8d0 --- /dev/null +++ b/src/main/stale-runtime-cache-cleanup.ts @@ -0,0 +1,76 @@ +import { readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' + +/** + * Removes runtime caches left by earlier GoodBuddy versions (PERF, disk). + * + * - `opencode-runtime//`: OpenCode's shared config and skills + * are prepared per app version and only the current version's directory is + * ever used, so every other version-named directory is stale (~50 MB each). + * - `continue-host/host-v-…/`: patched Continue host bundles; only + * the current layout is read. Older layouts are stale. Directories of the + * current layout (other Continue versions or digests) are left alone, as + * are run directories, logs, sessions and config files. + * + * Only names that match these exact patterns are touched, never anything + * else in the user data directory. Failures (a file in use, permissions) are + * ignored; the next start tries again. + */ +export type StaleRuntimeCacheCleanupOptions = { + userDataPath: string + appVersion: string + continueHostLayoutVersion: number +} + +const appVersionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u +const continueHostPattern = /^host-v(\d+)-/u + +async function directoryNames(path: string): Promise { + try { + return (await readdir(path, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => entry.name) + } catch { + return [] + } +} + +export function staleRuntimeCacheDirectories( + names: { opencode: readonly string[]; continueHost: readonly string[] }, + options: Pick +): { opencode: string[]; continueHost: string[] } { + return { + opencode: names.opencode.filter((name) => + appVersionPattern.test(name) && name !== options.appVersion), + continueHost: names.continueHost.filter((name) => { + const match = continueHostPattern.exec(name) + return match !== null && Number(match[1]) < options.continueHostLayoutVersion + }) + } +} + +export async function removeStaleRuntimeCaches( + options: StaleRuntimeCacheCleanupOptions +): Promise<{ removed: string[] }> { + const opencodeRoot = join(options.userDataPath, 'opencode-runtime') + const continueRoot = join(options.userDataPath, 'continue-host') + const stale = staleRuntimeCacheDirectories({ + opencode: await directoryNames(opencodeRoot), + continueHost: await directoryNames(continueRoot) + }, options) + const targets = [ + ...stale.opencode.map((name) => join(opencodeRoot, name)), + ...stale.continueHost.map((name) => join(continueRoot, name)) + ] + const removed: string[] = [] + // One at a time: this runs in the background and should not compete for disk. + for (const target of targets) { + try { + await rm(target, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 }) + removed.push(target) + } catch { + // Left for the next start. + } + } + return { removed } +}