Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 34 additions & 2 deletions packages/coding-agent/src/core/kernel/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -718,6 +719,37 @@ async function hashRuntimeSource(sourceDir: string): Promise<string> {
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<void> {
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<void> {
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[],
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing venv bypasses stale cleanup

Low Severity

discardVenvDir only runs when the current venv exists, so a staged directory remains uncollected when a previous rebuild renamed the venv and then failed before recreating it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ab946de. Configure here.

}

await bootstrapVenv(venv, pythonSkills, options);
Expand Down
87 changes: 87 additions & 0 deletions packages/coding-agent/test/kernel-venv-discard.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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([]);
});
});