diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3835a395ec..d15c0fea66 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Fixed the kernel venv rebuild failing on Windows when a kernel from the old venv was still running. + ## [0.7.0] - 2026-08-05 ### Breaking Changes diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 9b12b4b413..c9bd76fa56 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { constants, existsSync, readdirSync, readFileSync } from "node:fs"; -import { access, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { stderr, stdin } from "node:process"; @@ -53,6 +53,7 @@ const REQUIRED_HARNESS_METHODS = [ ]; const RUNTIME_READY_CHECK = `import inspect; import rlm; from rlm import McpIntegration; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert hasattr(rlm, 'run'); assert callable(rlm); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm); assert callable(rlm.host_request); assert callable(rlm.find_models); assert callable(rlm.rlm.find_models); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'scope' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert 'global_' in inspect.signature(rlm.harness.create_memory).parameters; assert 'global_' in inspect.signature(rlm.get_harness_state).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`; const BOOTSTRAP_VERSION_FILE = ".bootstrap-version"; +const STALE_VENV_SUFFIX = ".stale-"; const BOOTSTRAP_LOCK_NAME = ".bootstrap.lock"; const BOOTSTRAP_LOCK_RETRY_MS = 100; const BOOTSTRAP_LOCK_STALE_WITHOUT_PID_MS = 30_000; @@ -718,6 +719,37 @@ async function hashRuntimeSource(sourceDir: string): Promise { return `sha256:${hash.digest("hex")}`; } +// Windows keeps a running executable mapped, so deleting a venv whose python is +// still alive fails with EPERM and takes the whole rebuild down with it. Renaming +// the directory succeeds even then, which frees the path for the new venv; the +// renamed copy is deleted once nothing holds it, here or on a later rebuild. +export async function discardVenvDir(venv: string): Promise { + await removeStaleVenvDirs(venv); + const staged = `${venv}${STALE_VENV_SUFFIX}${process.pid}-${Date.now()}`; + try { + await rename(venv, staged); + } catch (error) { + if (isNodeError(error, "ENOENT")) return; + await rm(venv, { recursive: true, force: true }); + return; + } + await rm(staged, { recursive: true, force: true }).catch(() => undefined); +} + +async function removeStaleVenvDirs(venv: string): Promise { + const prefix = `${path.basename(venv)}${STALE_VENV_SUFFIX}`; + let entries: string[]; + try { + entries = await readdir(path.dirname(venv)); + } catch { + return; + } + for (const entry of entries) { + if (!entry.startsWith(prefix)) continue; + await rm(path.join(path.dirname(venv), entry), { recursive: true, force: true }).catch(() => undefined); + } +} + async function bootstrapVenv( venv: string, pythonSkills: readonly BootstrapPythonSkill[], @@ -902,7 +934,7 @@ async function ensureKernelPythonUncached( reportProgress(options, "› setting up python kernel (one-time, ~30s)…"); if (hadVenv) { reportProgress(options, "rebuilding kernel venv"); - await rm(venv, { recursive: true, force: true }); + await discardVenvDir(venv); } await bootstrapVenv(venv, pythonSkills, options); diff --git a/packages/coding-agent/test/kernel-venv-discard.test.ts b/packages/coding-agent/test/kernel-venv-discard.test.ts new file mode 100644 index 0000000000..76624b25f1 --- /dev/null +++ b/packages/coding-agent/test/kernel-venv-discard.test.ts @@ -0,0 +1,87 @@ +import { spawn } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { discardVenvDir } from "../src/core/kernel/bootstrap.js"; + +let tempDir = ""; + +const binDir = process.platform === "win32" ? "Scripts" : "bin"; +const executableName = process.platform === "win32" ? "python.exe" : "python"; + +function createVenv(): string { + const venv = join(tempDir, "kernel-venv"); + mkdirSync(join(venv, binDir), { recursive: true }); + // A copy of the current node binary stands in for the venv interpreter: running + // it produces the same mapped-image lock that a live kernel holds. + copyFileSync(process.execPath, join(venv, binDir, executableName)); + writeFileSync(join(venv, ".bootstrap-version"), "{}\n"); + return venv; +} + +function stagedDirs(venv: string): string[] { + return readdirSync(tempDir).filter((entry) => entry !== basename(venv)); +} + +describe("kernel venv discard", () => { + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "prime-agent-kernel-venv-discard-")); + }); + + afterEach(() => { + if (!tempDir) return; + rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + tempDir = ""; + }); + + it("removes an idle venv outright", async () => { + const venv = createVenv(); + + await discardVenvDir(venv); + + expect(existsSync(venv)).toBe(false); + expect(stagedDirs(venv)).toEqual([]); + }); + + it("does nothing when the venv is already gone", async () => { + await expect(discardVenvDir(join(tempDir, "kernel-venv"))).resolves.toBeUndefined(); + }); + + it("frees the venv path while its interpreter is still running", async () => { + const venv = createVenv(); + const interpreter = join(venv, binDir, executableName); + const child = spawn(interpreter, ["-e", "setTimeout(() => {}, 30_000)"], { + stdio: "ignore", + windowsHide: true, + }); + try { + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + + await discardVenvDir(venv); + + // The path must be reusable for the new venv even though the old + // interpreter still holds its image open. + expect(existsSync(venv)).toBe(false); + } finally { + const exited = new Promise((resolve) => child.once("exit", () => resolve())); + child.kill(); + await exited; + } + }); + + it("cleans up directories left behind by an earlier rebuild", async () => { + const venv = createVenv(); + const leftover = `${venv}.stale-1234-5678`; + mkdirSync(leftover, { recursive: true }); + writeFileSync(join(leftover, "python"), ""); + + await discardVenvDir(venv); + + expect(existsSync(leftover)).toBe(false); + expect(stagedDirs(venv)).toEqual([]); + }); +});