diff --git a/src/config.test.ts b/src/config.test.ts index 9bc8b4c9..ae24910b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -28,6 +28,12 @@ assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents assert.equal(loadConfig(baseEnv).subagents, false); assert.equal(loadConfig(baseEnv).artifactsEnabled, false); assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); +assert.equal(loadConfig(baseEnv).heapSnapshotThresholdBytes, undefined); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_HEAP_SNAPSHOT_THRESHOLD_BYTES: "1073741824" }) + .heapSnapshotThresholdBytes, + 1024 * 1024 * 1024, +); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); assert.equal( loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123" }).artifactMaxFileBytes, @@ -67,6 +73,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_HEAP_SNAPSHOT_THRESHOLD_BYTES: "0" }), + /Invalid DEVSPACE_HEAP_SNAPSHOT_THRESHOLD_BYTES: 0/, +); assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", diff --git a/src/config.ts b/src/config.ts index f8c8b995..b1424254 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,7 @@ export interface ServerConfig { worktreeRoot: string; artifactsEnabled: boolean; artifactMaxFileBytes: number; + heapSnapshotThresholdBytes?: number; skillsEnabled: boolean; skillPaths: string[]; devspaceSkillsDir: string; @@ -243,6 +244,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { DEFAULT_ARTIFACT_MAX_FILE_BYTES, "DEVSPACE_ARTIFACT_MAX_FILE_BYTES", ), + heapSnapshotThresholdBytes: + env.DEVSPACE_HEAP_SNAPSHOT_THRESHOLD_BYTES === undefined + ? undefined + : parsePositiveInteger( + env.DEVSPACE_HEAP_SNAPSHOT_THRESHOLD_BYTES, + 1, + "DEVSPACE_HEAP_SNAPSHOT_THRESHOLD_BYTES", + ), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), devspaceSkillsDir: devspaceSkillsDir(env), diff --git a/src/heap-snapshot-guard.ts b/src/heap-snapshot-guard.ts new file mode 100644 index 00000000..e4203941 --- /dev/null +++ b/src/heap-snapshot-guard.ts @@ -0,0 +1,88 @@ +import { chmodSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { writeHeapSnapshot } from "node:v8"; + +const DEFAULT_CHECK_INTERVAL_MS = 5 * 60 * 1_000; +const SNAPSHOT_PREFIX = "devspace-heap-"; +const SNAPSHOT_SUFFIX = ".heapsnapshot"; + +export interface HeapSnapshotGuardOptions { + stateDir: string; + thresholdBytes: number; + intervalMs?: number; + memoryUsage?: () => Pick; + now?: () => Date; + writeSnapshot?: (filename: string) => string; + onError?: (error: unknown) => void; +} + +export interface HeapSnapshotGuard { + checkNow(): string | undefined; + stop(): void; +} + +export function startHeapSnapshotGuard( + options: HeapSnapshotGuardOptions, +): HeapSnapshotGuard { + const thresholdBytes = positiveInteger(options.thresholdBytes, "thresholdBytes"); + const intervalMs = positiveInteger( + options.intervalMs ?? DEFAULT_CHECK_INTERVAL_MS, + "intervalMs", + ); + const diagnosticsDir = join(options.stateDir, "diagnostics"); + const memoryUsage = options.memoryUsage ?? process.memoryUsage; + const now = options.now ?? (() => new Date()); + const writeSnapshot = options.writeSnapshot ?? writeHeapSnapshot; + let captured = hasExistingSnapshot(diagnosticsDir); + let timer: NodeJS.Timeout | undefined; + + const stop = () => { + if (!timer) return; + clearInterval(timer); + timer = undefined; + }; + + const checkNow = (): string | undefined => { + if (captured || memoryUsage().rss < thresholdBytes) return undefined; + + try { + mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 }); + chmodSync(diagnosticsDir, 0o700); + const timestamp = now().toISOString().replaceAll(":", "-"); + const filename = join( + diagnosticsDir, + `${SNAPSHOT_PREFIX}${timestamp}-${process.pid}${SNAPSHOT_SUFFIX}`, + ); + const writtenPath = writeSnapshot(filename); + chmodSync(writtenPath, 0o600); + captured = true; + stop(); + return writtenPath; + } catch (error) { + options.onError?.(error); + return undefined; + } + }; + + checkNow(); + if (!captured) { + timer = setInterval(checkNow, intervalMs); + timer.unref(); + } + + return { checkNow, stop }; +} + +function hasExistingSnapshot(diagnosticsDir: string): boolean { + if (!existsSync(diagnosticsDir)) return false; + return readdirSync(diagnosticsDir).some( + (name) => name.startsWith(SNAPSHOT_PREFIX) && name.endsWith(SNAPSHOT_SUFFIX), + ); +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + return value; +} diff --git a/src/process-sessions.ts b/src/process-sessions.ts index f414df19..4755a21f 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -222,6 +222,10 @@ export class ProcessSessionManager { this.completedSessionTtlMs = options.completedSessionTtlMs ?? COMPLETED_SESSION_TTL_MS; } + get size(): number { + return this.sessions.size; + } + async start(input: StartCommandInput): Promise { const session = this.createSession(input); this.sessions.set(session.id, session); diff --git a/src/server.test.ts b/src/server.test.ts index 4d6dd504..c09731ab 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; +import { existsSync, writeFileSync } from "node:fs"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,10 +9,11 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadConfig, type ServerConfig } from "./config.js"; +import { startHeapSnapshotGuard } from "./heap-snapshot-guard.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; -import { createMcpServer } from "./server.js"; +import { createMcpServer, createServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; @@ -183,6 +185,119 @@ test("checkout reuse and context suppression survive a registry restart", async } }); +test("heap snapshot guard captures one diagnostic after the configured threshold", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-heap-guard-test-")); + let rss = 512; + let writes = 0; + const guard = startHeapSnapshotGuard({ + stateDir: root, + thresholdBytes: 1_024, + intervalMs: 60_000, + memoryUsage: () => ({ rss }), + now: () => new Date("2026-08-21T05:00:00.000Z"), + writeSnapshot: (filename) => { + writes += 1; + writeFileSync(filename, "snapshot"); + return filename; + }, + }); + t.after(() => { + guard.stop(); + return rm(root, { recursive: true, force: true }); + }); + + assert.equal(guard.checkNow(), undefined); + rss = 2_048; + const snapshotPath = guard.checkNow(); + assert.ok(snapshotPath); + assert.equal(existsSync(snapshotPath), true); + assert.equal(writes, 1); + assert.equal(guard.checkNow(), undefined); + assert.equal(writes, 1); + + const restoredGuard = startHeapSnapshotGuard({ + stateDir: root, + thresholdBytes: 1_024, + intervalMs: 60_000, + memoryUsage: () => ({ rss }), + writeSnapshot: (filename) => { + writes += 1; + writeFileSync(filename, "unexpected"); + return filename; + }, + }); + restoredGuard.stop(); + assert.equal(restoredGuard.checkNow(), undefined); + assert.equal(writes, 1); +}); + +test("health endpoint reports bounded runtime state without exposing paths", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-health-test-")); + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_STATE_DIR: join(root, ".state"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_AGENT_DIR: join(root, "agent"), + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + DEVSPACE_LOG_LEVEL: "silent", + PORT: "1", + }); + const running = createServer(config, { incomingArtifactAdapters: [] }); + const httpServer = running.app.listen(0, "127.0.0.1"); + await new Promise((resolve, reject) => { + httpServer.once("listening", resolve); + httpServer.once("error", reject); + }); + t.after(async () => { + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve())); + }); + await running.close(); + await rm(root, { recursive: true, force: true }); + }); + + const address = httpServer.address(); + assert.ok(address && typeof address === "object"); + const response = await fetch(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(response.status, 200); + const body = (await response.json()) as { + ok: boolean; + name: string; + memory: Record; + sessions: { + mcp: number; + process: number; + workspaceCache: { + cachedWorkspaces: number; + maxCachedWorkspaces: number; + workspaceIdleTimeoutMs: number; + oldestIdleMs: number; + }; + persistedWorkspaces: number; + conversationBindings: number; + }; + }; + + assert.equal(body.ok, true); + assert.equal(body.name, "devspace"); + assert.ok(body.memory.rssBytes > 0); + assert.ok(body.memory.heapUsedBytes > 0); + assert.deepEqual(body.sessions, { + mcp: 0, + process: 0, + workspaceCache: { + cachedWorkspaces: 0, + maxCachedWorkspaces: 32, + workspaceIdleTimeoutMs: 60 * 60 * 1_000, + oldestIdleMs: 0, + }, + persistedWorkspaces: 0, + conversationBindings: 0, + }); + assert.equal(JSON.stringify(body).includes(root), false); +}); + interface ServerFixture { client: Client; project: string; diff --git a/src/server.ts b/src/server.ts index 477986b7..cc71f22b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,7 @@ import { registerArtifactTools, } from "./artifact-tools.js"; import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; +import { startHeapSnapshotGuard } from "./heap-snapshot-guard.js"; import { createOpenAIIncomingArtifactAdapter, type IncomingArtifactAdapter, @@ -65,8 +66,10 @@ import { type Transport = StreamableHTTPServerTransport; // MCP clients can reconnect without closing the previous transport. Bound stale // session retention so abandoned MCP servers do not accumulate for the life of the process. -const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000; -const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000; +const MCP_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; +const RUNTIME_MAINTENANCE_INTERVAL_MS = 5 * 60 * 1_000; +const WORKSPACE_BINDING_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1_000; +const WORKSPACE_BINDING_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html"; const WRITE_TOOL_ANNOTATIONS = { @@ -1699,6 +1702,17 @@ export function createServer( const localAgentProviders = config.subagents ? getLocalAgentProviderAvailabilitySnapshot() : []; + const heapSnapshotGuard = config.heapSnapshotThresholdBytes + ? startHeapSnapshotGuard({ + stateDir: config.stateDir, + thresholdBytes: config.heapSnapshotThresholdBytes, + onError: (error) => { + logEvent(config.logging, "warn", "heap_snapshot_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }, + }) + : undefined; const logSessionCloseResults = ( reason: "idle_timeout" | "server_shutdown", @@ -1724,12 +1738,33 @@ export function createServer( } }; - const sessionCleanupTimer = setInterval(() => { + let lastBindingPruneAtMs = Number.NEGATIVE_INFINITY; + const pruneWorkspaceState = () => { + const now = Date.now(); + const cachedWorkspacesRemoved = workspaces.pruneIdleWorkspaces(); + let conversationBindingsRemoved = 0; + if (now - lastBindingPruneAtMs >= WORKSPACE_BINDING_PRUNE_INTERVAL_MS) { + const cutoffIso = new Date(now - WORKSPACE_BINDING_RETENTION_MS).toISOString(); + conversationBindingsRemoved = + workspaceStore.pruneStaleConversationBindings(cutoffIso); + lastBindingPruneAtMs = now; + } + if (cachedWorkspacesRemoved > 0 || conversationBindingsRemoved > 0) { + logEvent(config.logging, "info", "workspace_state_pruned", { + cachedWorkspacesRemoved, + conversationBindingsRemoved, + }); + } + }; + + pruneWorkspaceState(); + const runtimeMaintenanceTimer = setInterval(() => { + pruneWorkspaceState(); void transports .closeIdle(MCP_SESSION_IDLE_TIMEOUT_MS) .then((results) => logSessionCloseResults("idle_timeout", results)); - }, MCP_SESSION_CLEANUP_INTERVAL_MS); - sessionCleanupTimer.unref(); + }, RUNTIME_MAINTENANCE_INTERVAL_MS); + runtimeMaintenanceTimer.unref(); if (config.logging.trustProxy) { app.set("trust proxy", true); @@ -1785,7 +1820,25 @@ export function createServer( ); app.get("/healthz", (_req, res) => { - res.json({ ok: true, name: "devspace" }); + const memory = process.memoryUsage(); + const persistedWorkspaces = workspaceStore.getStats(); + res.json({ + ok: true, + name: "devspace", + memory: { + rssBytes: memory.rss, + heapUsedBytes: memory.heapUsed, + heapTotalBytes: memory.heapTotal, + externalBytes: memory.external, + }, + sessions: { + mcp: transports.size, + process: processSessions.size, + workspaceCache: workspaces.getStats(), + persistedWorkspaces: persistedWorkspaces.workspaceSessions, + conversationBindings: persistedWorkspaces.conversationBindings, + }, + }); }); app.all("/mcp", async (req, res) => { @@ -1886,10 +1939,11 @@ export function createServer( localAgentProviders, close: () => { closePromise ??= (async () => { - clearInterval(sessionCleanupTimer); + clearInterval(runtimeMaintenanceTimer); const results = await transports.closeAll(); logSessionCloseResults("server_shutdown", results); processSessions.shutdown(); + heapSnapshotGuard?.stop(); oauthProvider.close(); workspaceStore.close?.(); })(); diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 5af9f991..1b251746 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -26,6 +26,41 @@ test("a conversation reuses its checkout context", async (t) => { assert.deepEqual(second.workspace.agentProfiles, first.workspace.agentProfiles); }); +test("workspace store prunes stale conversation bindings without deleting sessions", async (t) => { + const context = await fixture(t); + const oldWorkspace = await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-old", + }); + await context.registry.openWorkspace(context.project, { + conversationScopeId: "chat-current", + }); + + const database = openDatabase(context.stateDir); + try { + database.sqlite + .prepare( + "update workspace_conversation_bindings set last_used_at = ? where conversation_scope_id = ?", + ) + .run("2026-01-01T00:00:00.000Z", "chat-old"); + } finally { + database.close(); + } + + assert.deepEqual(context.store.getStats(), { + workspaceSessions: 2, + conversationBindings: 2, + }); + assert.equal( + context.store.pruneStaleConversationBindings("2026-02-01T00:00:00.000Z"), + 1, + ); + assert.deepEqual(context.store.getStats(), { + workspaceSessions: 2, + conversationBindings: 1, + }); + assert.equal(context.store.getSession(oldWorkspace.workspace.id)?.status, "active"); +}); + test("different conversations receive separate checkout workspaces", async (t) => { const { project, registry } = await fixture(t); diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 88a70e2e..d5f2ea22 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -22,6 +22,11 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspaceStoreStats { + workspaceSessions: number; + conversationBindings: number; +} + export interface WorkspaceConversationBinding { conversationScopeId: string; targetKey: string; @@ -53,6 +58,8 @@ export interface WorkspaceStore { }): WorkspaceConversationBinding; touchConversationBinding(conversationScopeId: string, targetKey: string): void; deleteConversationBinding(conversationScopeId: string, targetKey: string): void; + getStats(): WorkspaceStoreStats; + pruneStaleConversationBindings(cutoffIso: string): number; close?(): void; } @@ -201,6 +208,27 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + getStats(): WorkspaceStoreStats { + const workspaceSessions = this.database.sqlite + .prepare("select count(*) as count from workspace_sessions") + .get() as { count: number }; + const conversationBindings = this.database.sqlite + .prepare("select count(*) as count from workspace_conversation_bindings") + .get() as { count: number }; + return { + workspaceSessions: workspaceSessions.count, + conversationBindings: conversationBindings.count, + }; + } + + pruneStaleConversationBindings(cutoffIso: string): number { + return this.database.sqlite + .prepare( + "delete from workspace_conversation_bindings where last_used_at < ?", + ) + .run(cutoffIso).changes; + } + close(): void { this.database.close(); } diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 8584c1b7..1f9972a3 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -129,6 +129,50 @@ test("persisted checkout and worktree sessions restore after recreating the regi } }); +test("workspace cache evicts least-recently-used contexts and restores them by id", async (t) => { + const context = await fixture(t); + const stateDir = join(context.root, ".bounded-state"); + const store = new SqliteWorkspaceStore(stateDir); + t.after(() => store.close()); + + let now = 0; + const registry = new WorkspaceRegistry(context.config, store, { + maxCachedWorkspaces: 2, + workspaceIdleTimeoutMs: 100, + now: () => now, + }); + const roots = ["project-a", "project-b", "project-c"].map((name) => join(context.root, name)); + await Promise.all(roots.map((root) => mkdir(root))); + + const first = await registry.openWorkspace(roots[0]!); + now = 10; + await registry.openWorkspace(roots[1]!); + now = 20; + const third = await registry.openWorkspace(roots[2]!); + + assert.equal(registry.getStats().cachedWorkspaces, 2); + + now = 30; + const restoredFirst = registry.getWorkspace(first.workspace.id); + assert.equal(restoredFirst.id, first.workspace.id); + assert.equal(restoredFirst.root, roots[0]); + assert.equal(registry.getStats().cachedWorkspaces, 2); + + now = 121; + registry.getWorkspace(restoredFirst.id); + assert.equal(registry.pruneIdleWorkspaces(), 1); + assert.deepEqual(registry.getStats(), { + cachedWorkspaces: 1, + maxCachedWorkspaces: 2, + workspaceIdleTimeoutMs: 100, + oldestIdleMs: 0, + }); + + const restoredThird = registry.getWorkspace(third.workspace.id); + assert.equal(restoredThird.root, roots[2]); + assert.equal(registry.getStats().cachedWorkspaces, 2); +}); + test("workspace paths outside the allowed roots are rejected", async (t) => { const context = await fixture(t); diff --git a/src/workspaces.ts b/src/workspaces.ts index 30762648..90ee313f 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -82,20 +82,51 @@ export interface OpenWorkspaceOptions { conversationScopeId?: string; } +export interface WorkspaceRegistryOptions { + maxCachedWorkspaces?: number; + workspaceIdleTimeoutMs?: number; + now?: () => number; +} + +export interface WorkspaceRegistryStats { + cachedWorkspaces: number; + maxCachedWorkspaces: number; + workspaceIdleTimeoutMs: number; + oldestIdleMs: number; +} + type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; mkdir: (path: string, options: { recursive: true }) => Promise; }; +const DEFAULT_MAX_CACHED_WORKSPACES = 32; +const DEFAULT_WORKSPACE_IDLE_TIMEOUT_MS = 60 * 60 * 1_000; + export class WorkspaceRegistry { private readonly workspaces = new Map(); + private readonly workspaceLastAccessMs = new Map(); private readonly pendingCheckoutOpens = new Map>(); + private readonly maxCachedWorkspaces: number; + private readonly workspaceIdleTimeoutMs: number; + private readonly now: () => number; constructor( private readonly config: ServerConfig, private readonly store?: WorkspaceStore, - ) {} + options: WorkspaceRegistryOptions = {}, + ) { + this.maxCachedWorkspaces = positiveInteger( + options.maxCachedWorkspaces, + DEFAULT_MAX_CACHED_WORKSPACES, + ); + this.workspaceIdleTimeoutMs = positiveInteger( + options.workspaceIdleTimeoutMs, + DEFAULT_WORKSPACE_IDLE_TIMEOUT_MS, + ); + this.now = options.now ?? Date.now; + } async openWorkspace( input: string | OpenWorkspaceInput, @@ -174,7 +205,7 @@ export class WorkspaceRegistry { }; } - this.workspaces.delete(binding.workspaceSessionId); + this.forgetWorkspace(binding.workspaceSessionId); this.store?.deleteConversationBinding(conversationScopeId, targetKey); } @@ -245,6 +276,7 @@ export class WorkspaceRegistry { getWorkspace(workspaceId: string): Workspace { const workspace = this.workspaces.get(workspaceId); if (workspace) { + this.touchWorkspace(workspaceId); this.store?.touchSession(workspaceId); return workspace; } @@ -278,11 +310,37 @@ export class WorkspaceRegistry { activatedSkillDirs: new Set(), }; this.store?.touchSession(workspaceId); - this.workspaces.set(restoredWorkspace.id, restoredWorkspace); + this.rememberWorkspace(restoredWorkspace); return restoredWorkspace; } + getStats(): WorkspaceRegistryStats { + const now = this.now(); + let oldestIdleMs = 0; + for (const lastAccessMs of this.workspaceLastAccessMs.values()) { + oldestIdleMs = Math.max(oldestIdleMs, Math.max(0, now - lastAccessMs)); + } + + return { + cachedWorkspaces: this.workspaces.size, + maxCachedWorkspaces: this.maxCachedWorkspaces, + workspaceIdleTimeoutMs: this.workspaceIdleTimeoutMs, + oldestIdleMs, + }; + } + + pruneIdleWorkspaces(): number { + const now = this.now(); + let removed = 0; + for (const [workspaceId, lastAccessMs] of this.workspaceLastAccessMs) { + if (now - lastAccessMs <= this.workspaceIdleTimeoutMs) continue; + this.forgetWorkspace(workspaceId); + removed += 1; + } + return removed; + } + resolvePath(workspace: Workspace, inputPath: string): string { const absolutePath = resolveAllowedPath(inputPath, workspace.root, [workspace.root]); if (!isPathInsideRoot(absolutePath, workspace.root)) { @@ -376,7 +434,7 @@ export class WorkspaceRegistry { baseSha: workspace.worktree?.baseSha, managed: workspace.worktree?.managed, }); - this.workspaces.set(workspace.id, workspace); + this.rememberWorkspace(workspace); const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); @@ -389,6 +447,33 @@ export class WorkspaceRegistry { }; } + private rememberWorkspace(workspace: Workspace): void { + this.pruneIdleWorkspaces(); + this.workspaces.set(workspace.id, workspace); + this.touchWorkspace(workspace.id); + + while (this.workspaces.size > this.maxCachedWorkspaces) { + let oldestWorkspaceId: string | undefined; + let oldestAccessMs = Number.POSITIVE_INFINITY; + for (const [workspaceId, lastAccessMs] of this.workspaceLastAccessMs) { + if (lastAccessMs >= oldestAccessMs) continue; + oldestWorkspaceId = workspaceId; + oldestAccessMs = lastAccessMs; + } + if (!oldestWorkspaceId) break; + this.forgetWorkspace(oldestWorkspaceId); + } + } + + private touchWorkspace(workspaceId: string): void { + this.workspaceLastAccessMs.set(workspaceId, this.now()); + } + + private forgetWorkspace(workspaceId: string): void { + this.workspaces.delete(workspaceId); + this.workspaceLastAccessMs.delete(workspaceId); + } + private loadSkillsForWorkspace(root: string): Pick { const result = loadWorkspaceSkills(this.config, root); return { @@ -461,6 +546,14 @@ export class WorkspaceRegistry { } } +function positiveInteger(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Expected a positive integer, received: ${value}`); + } + return value; +} + async function canonicalPath(path: string): Promise { const missingSegments: string[] = []; let candidate = path;