From ab946de0a2b13840cff89f442bac4a22084c3b94 Mon Sep 17 00:00:00 2001 From: Nikita <62756604+FlamesONE@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:28:41 +0500 Subject: [PATCH] fix(coding-agent): rebuild the kernel venv while a kernel is running on Windows Windows keeps a running executable mapped, so removing a venv whose python is still alive fails with EPERM and aborts the whole bootstrap. Rename the venv out of the way instead, which succeeds even then, and delete the renamed copy once nothing holds it. --- packages/coding-agent/CHANGELOG.md | 2 + .../coding-agent/src/core/kernel/bootstrap.ts | 36 +++++++- .../test/kernel-venv-discard.test.ts | 87 +++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/kernel-venv-discard.test.ts 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([]); + }); +});