Skip to content
Merged
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
98 changes: 76 additions & 22 deletions packages/coding-agent/src/core/kernel/bootstrap.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium

function runtimeCandidateDirs(): string[] {

resolveRuntimeSourceDir() returns the first candidate directory containing a pyproject.toml, and runtimeCandidateDirs() lists dist/prime-agent-runtime ahead of the live source checkout. After packages/coding-agent is built once, the dist/prime-agent-runtime snapshot becomes stale relative to the real source tree, but resolveRuntimeIdentity() and bootstrapVenv() still hash and install from that stale copy. The kernel venv then uses outdated runtime code and never rebuilds when the actual local runtime source changes, defeating the staleness tracking this PR adds. Consider preferring the live source checkout when it exists, or documenting why the built copy should take precedence.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/kernel/bootstrap.ts around line 530:

`resolveRuntimeSourceDir()` returns the first candidate directory containing a `pyproject.toml`, and `runtimeCandidateDirs()` lists `dist/prime-agent-runtime` ahead of the live source checkout. After `packages/coding-agent` is built once, the `dist/prime-agent-runtime` snapshot becomes stale relative to the real source tree, but `resolveRuntimeIdentity()` and `bootstrapVenv()` still hash and install from that stale copy. The kernel venv then uses outdated runtime code and never rebuilds when the actual local runtime source changes, defeating the staleness tracking this PR adds. Consider preferring the live source checkout when it exists, or documenting why the built copy should take precedence.

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, readFileSync } from "node:fs";
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { access, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { stderr, stdin } from "node:process";
Expand All @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
import { getPackageDir } from "../../config.js";
import type { PythonSkillRuntimeInfo } from "../skills.js";

const BOOTSTRAP_SCHEMA = 7;
const BOOTSTRAP_SCHEMA = 8;
const PYTHON_VERSION = "3.11";
const IPYKERNEL_REQUIREMENT = "ipykernel";
const RUNTIME_REQUIREMENT = "prime-agent-runtime";
Expand Down Expand Up @@ -51,7 +51,7 @@ const REQUIRED_HARNESS_METHODS = [
"delete_prompt_note",
"record_refinement",
];
const RUNTIME_READY_CHECK = `import inspect; import rlm; 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 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 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`;
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 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 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`;
const BOOTSTRAP_VERSION_FILE = ".bootstrap-version";
const BOOTSTRAP_LOCK_NAME = ".bootstrap.lock";
const BOOTSTRAP_LOCK_RETRY_MS = 100;
Expand Down Expand Up @@ -491,28 +491,35 @@ function pythonSkillsMatch(a: BootstrapPythonSkill[] | undefined, b: readonly Bo

function bootstrapVersionCurrent(
version: BootstrapVersion | null,
runtimeIdentity: string,
pythonSkills: readonly BootstrapPythonSkill[],
): boolean {
return (
version !== null && bootstrapBaseVersionCurrent(version) && pythonSkillsMatch(version.pythonSkills, pythonSkills)
version !== null &&
bootstrapBaseVersionCurrent(version, runtimeIdentity) &&
pythonSkillsMatch(version.pythonSkills, pythonSkills)
);
}

function bootstrapBaseVersionCurrent(version: BootstrapVersion | null): boolean {
function bootstrapBaseVersionCurrent(version: BootstrapVersion | null, runtimeIdentity: string): boolean {
return (
version?.schema === BOOTSTRAP_SCHEMA &&
version.ipykernel === IPYKERNEL_REQUIREMENT &&
version.runtime === RUNTIME_REQUIREMENT &&
version.runtime === runtimeIdentity &&
version.snapshot === STATE_SNAPSHOT_REQUIREMENT &&
extraUvArgsMatch(version.extraUvArgs, DEFAULT_RLM_EXTRA_UV_ARGS)
);
}

async function writeBootstrapVersion(venv: string, pythonSkills: readonly BootstrapPythonSkill[]): Promise<void> {
async function writeBootstrapVersion(
venv: string,
runtimeIdentity: string,
pythonSkills: readonly BootstrapPythonSkill[],
): Promise<void> {
const version: BootstrapVersion = {
schema: BOOTSTRAP_SCHEMA,
ipykernel: IPYKERNEL_REQUIREMENT,
runtime: RUNTIME_REQUIREMENT,
runtime: runtimeIdentity,
snapshot: STATE_SNAPSHOT_REQUIREMENT,
extraUvArgs: DEFAULT_RLM_EXTRA_UV_ARGS,
pythonSkills: [...pythonSkills],
Expand All @@ -522,21 +529,64 @@ async function writeBootstrapVersion(venv: string, pythonSkills: readonly Bootst

function runtimeCandidateDirs(): string[] {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
// dist/prime-agent-runtime is listed first deliberately: it is the only path stable
// across every shipped layout (dist/, dist/bundle/, bun), where import.meta.url-relative
// resolution breaks. `npm run build` rebuilds it from live source (copy-assets does
// rm -rf + cp), so the staleness hash still refreshes on every build. The relative
// paths below cover running from source (tsx) where dist/ hasn't been built.
return [
// Stable across layouts (dist/, dist/bundle/, tsx): <package>/dist/prime-agent-runtime
path.join(getPackageDir(), "dist", "prime-agent-runtime"),
path.resolve(moduleDir, "..", "..", "prime-agent-runtime"),
path.resolve(moduleDir, "..", "..", "..", "..", "..", "prime-agent-runtime"),
];
}

async function resolveRuntimeRequirement(): Promise<string> {
async function resolveRuntimeSourceDir(): Promise<string | null> {
for (const candidate of runtimeCandidateDirs()) {
if (await exists(path.join(candidate, "pyproject.toml"))) {
return candidate;
}
}
return RUNTIME_REQUIREMENT;
return null;
}

// Identity of the runtime to be installed. For a local source checkout this is a
// content hash of every rlm/*.py file plus pyproject.toml, so any runtime code or
// dependency change invalidates an existing venv automatically. Falls back to the
// bare package name when the runtime resolves to a registry install (no local source).
export async function resolveRuntimeIdentity(): Promise<string> {
const sourceDir = await resolveRuntimeSourceDir();
if (!sourceDir) return RUNTIME_REQUIREMENT;
return hashRuntimeSource(sourceDir);
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

// Throws if the local source can't be read. A failure here must surface rather than
// fall back to RUNTIME_REQUIREMENT: that constant is the registry-install identity, and
// recording it for a local checkout would permanently mask later source changes.
async function hashRuntimeSource(sourceDir: string): Promise<string> {
const rlmDir = path.join(sourceDir, "src", "rlm");
const files: string[] = [path.join(sourceDir, "pyproject.toml")];
async function collect(dir: string): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await collect(full);
} else if (entry.isFile() && entry.name.endsWith(".py")) {
files.push(full);
}
}
}
await collect(rlmDir);
files.sort();
const hash = createHash("sha256");
for (const file of files) {
hash.update(path.relative(sourceDir, file));
hash.update("\0");
hash.update(await readFile(file));
hash.update("\0");
}
return `sha256:${hash.digest("hex")}`;
}

async function bootstrapVenv(
Expand All @@ -546,7 +596,9 @@ async function bootstrapVenv(
): Promise<void> {
await mkdir(path.dirname(venv), { recursive: true });
const python = path.join(venv, "bin", "python");
const runtimeRequirement = await resolveRuntimeRequirement();
const sourceDir = await resolveRuntimeSourceDir();
const runtimeRequirement = sourceDir ?? RUNTIME_REQUIREMENT;
const runtimeIdentity = await resolveRuntimeIdentity();

// Build the step list before running so the percentage denominator only
// counts work that will actually happen on this machine.
Expand Down Expand Up @@ -579,17 +631,17 @@ async function bootstrapVenv(
STATE_SNAPSHOT_REQUIREMENT,
...DEFAULT_RLM_EXTRA_UV_ARGS,
]);

if (pythonSkills.length > 0) {
progress.begin(BOOTSTRAP_STEP.skills);
}
await syncPythonSkills(uv, venv, python, pythonSkills, options);
await syncPythonSkills(uv, venv, python, runtimeIdentity, pythonSkills, options);
}

async function syncPythonSkills(
uv: string,
venv: string,
python: string,
runtimeIdentity: string,
pythonSkills: readonly BootstrapPythonSkill[],
options: EnsureKernelPythonOptions,
): Promise<void> {
Expand All @@ -616,26 +668,27 @@ async function syncPythonSkills(
);
}
}
await writeBootstrapVersion(venv, installedPythonSkills);
await writeBootstrapVersion(venv, runtimeIdentity, installedPythonSkills);
}

async function kernelBaseReady(python: string, venv: string): Promise<boolean> {
async function kernelBaseReady(python: string, venv: string, runtimeIdentity: string): Promise<boolean> {
return (
(await hasIpykernel(python)) &&
(await hasPrimeAgentRuntime(python)) &&
bootstrapBaseVersionCurrent(await readBootstrapVersion(venv))
bootstrapBaseVersionCurrent(await readBootstrapVersion(venv), runtimeIdentity)
);
}

async function kernelReady(
python: string,
venv: string,
runtimeIdentity: string,
pythonSkills: readonly BootstrapPythonSkill[],
): Promise<boolean> {
return (
(await hasIpykernel(python)) &&
(await hasPrimeAgentRuntime(python)) &&
bootstrapVersionCurrent(await readBootstrapVersion(venv), pythonSkills)
bootstrapVersionCurrent(await readBootstrapVersion(venv), runtimeIdentity, pythonSkills)
);
}

Expand Down Expand Up @@ -682,13 +735,14 @@ async function ensureKernelPythonUncached(

const venv = await resolveWritableKernelVenvDir();
const python = path.join(venv, "bin", "python");
if (await kernelReady(python, venv, pythonSkills)) return python;
const runtimeIdentity = await resolveRuntimeIdentity();
if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python;

const releaseLock = await acquireBootstrapLock(venv);
try {
if (await kernelReady(python, venv, pythonSkills)) return python;
if (await kernelBaseReady(python, venv)) {
await syncPythonSkills(await ensureUv(options), venv, python, pythonSkills, options);
if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python;
if (await kernelBaseReady(python, venv, runtimeIdentity)) {
await syncPythonSkills(await ensureUv(options), venv, python, runtimeIdentity, pythonSkills, options);
return python;
}

Expand Down
40 changes: 35 additions & 5 deletions packages/coding-agent/test/kernel-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
ensureKernelPython,
getKernelVenvDir,
type KernelPythonSkill,
resolveRuntimeIdentity,
} from "../src/core/kernel/bootstrap.js";

let tempDir = "";
let originalEnv: NodeJS.ProcessEnv;
let runtimeIdentity = "";

function pyprojectHash(pyprojectPath: string): string {
return `sha256:${createHash("sha256").update(readFileSync(pyprojectPath)).digest("hex")}`;
Expand All @@ -27,9 +29,9 @@ function writeBootstrapVersion(venv: string, pythonSkills: readonly KernelPython
writeFileSync(
join(venv, ".bootstrap-version"),
`${JSON.stringify({
schema: 7,
schema: 8,
ipykernel: "ipykernel",
runtime: "prime-agent-runtime",
runtime: runtimeIdentity,
snapshot: "dill",
extraUvArgs: DEFAULT_RLM_EXTRA_UV_ARGS,
pythonSkills: pythonSkills.map((skill) => ({
Expand Down Expand Up @@ -133,7 +135,8 @@ function installFakeUv(): string {
}

describe("kernel bootstrap", () => {
beforeEach(() => {
beforeEach(async () => {
runtimeIdentity = await resolveRuntimeIdentity();
originalEnv = { ...process.env };
tempDir = mkdtempSync(join(tmpdir(), "prime-agent-kernel-bootstrap-"));
process.env.HOME = tempDir;
Expand Down Expand Up @@ -177,13 +180,14 @@ describe("kernel bootstrap", () => {
}
const version = JSON.parse(readFileSync(join(venv, ".bootstrap-version"), "utf8"));
expect(version).toEqual({
schema: 7,
schema: 8,
ipykernel: "ipykernel",
runtime: "prime-agent-runtime",
runtime: runtimeIdentity,
snapshot: "dill",
extraUvArgs: DEFAULT_RLM_EXTRA_UV_ARGS,
pythonSkills: [],
});
expect(version.runtime).toMatch(/^sha256:/);
});

it("routes bootstrap progress through the provided callback", async () => {
Expand Down Expand Up @@ -367,6 +371,32 @@ dependencies = ["httpx"]
await expect(ensureKernelPython()).resolves.toBe(python);
});

it("rebuilds a warm venv whose recorded runtime hash no longer matches local source", async () => {
const logPath = installFakeUv();
const venv = join(tempDir, "kernel-venv");
const python = join(venv, "bin", "python");
mkdirSync(join(venv, "bin"), { recursive: true });
writeFakePython(python, ["ipykernel", "rlm", ...DEFAULT_RLM_EXTRA_IMPORT_NAMES]);
writeFileSync(
join(venv, ".bootstrap-version"),
`${JSON.stringify({
schema: 8,
ipykernel: "ipykernel",
runtime: "sha256:stale",
snapshot: "dill",
extraUvArgs: DEFAULT_RLM_EXTRA_UV_ARGS,
pythonSkills: [],
})}\n`,
);
process.env.PRIME_AGENT_KERNEL_VENV = venv;

await expect(ensureKernelPython()).resolves.toBe(python);

expect(readFileSync(logPath, "utf8")).toContain(`venv ${venv} --python 3.11 --seed`);
const version = JSON.parse(readFileSync(join(venv, ".bootstrap-version"), "utf8"));
expect(version.runtime).toBe(runtimeIdentity);
});

it("rebuilds a warm venv with a stale rlm runtime", async () => {
const logPath = installFakeUv();
const venv = join(tempDir, "kernel-venv");
Expand Down