mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-10-19 07:23:59 +00:00
fix(coding-agent): keep codemode working after the install is updated or removed
Resolve the QuickJS wasm path once and spawn the bundled codemode worker from an in-memory data: URL. Show a restart hint when errors occur after the install changed on disk. closes #10439
This commit is contained in:
parent
f5d20047b3
commit
1b094148b9
5 changed files with 113 additions and 11 deletions
|
|
@ -5,6 +5,7 @@
|
|||
### Fixed
|
||||
|
||||
- Fixed subscription logins such as Sign in with ChatGPT failing with `refresh_token_invalidated` after a request was cancelled during an OAuth token refresh
|
||||
- Fixed codemode failing for the rest of a session after a pnpm global update removed the running install, and added a restart hint when errors occur after pi was updated or removed on disk ([#10439](https://github.com/earendil-works/pi/issues/10439))
|
||||
|
||||
## [1.0.2] - 2026-10-04
|
||||
|
||||
|
|
|
|||
|
|
@ -480,16 +480,20 @@ export function getBundledInteractiveAssetPath(name: string): string {
|
|||
return join(getInteractiveAssetsDir(), name);
|
||||
}
|
||||
|
||||
let embeddedQuickJSWasmPath: string | undefined;
|
||||
let quickJSWasmPath: string | undefined;
|
||||
|
||||
/** Called by the Bun entry with the path of the QuickJS wasm file embedded in the compiled executable. */
|
||||
export function setEmbeddedQuickJSWasmPath(path: string): void {
|
||||
embeddedQuickJSWasmPath = path;
|
||||
quickJSWasmPath = path;
|
||||
}
|
||||
|
||||
/** Get path to `quickjs-wasi/quickjs.wasm`, the VM that runs codemode scripts. */
|
||||
/**
|
||||
* Get path to `quickjs-wasi/quickjs.wasm`, the VM that runs codemode scripts. Resolved once so the
|
||||
* compiled module cached per path keeps working after an update removes this install (#10439).
|
||||
*/
|
||||
export function getQuickJSWasmPath(): string {
|
||||
return embeddedQuickJSWasmPath ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
|
||||
quickJSWasmPath ??= createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
|
||||
return quickJSWasmPath;
|
||||
}
|
||||
|
||||
/** Resolve the codemode worker entry for a release runtime. */
|
||||
|
|
@ -505,13 +509,44 @@ export function resolveCodemodeWorkerSpecifier(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
let codemodeWorkerDataUrl: URL | undefined;
|
||||
|
||||
/**
|
||||
* Get the codemode worker entry, or undefined to use the worker that ships next to pi-codemode.
|
||||
* The Bun and Node release builds both pass the worker as an extra entrypoint.
|
||||
*/
|
||||
export function getCodemodeWorkerSpecifier(): string | URL | undefined {
|
||||
const runtime = isBunBinary ? "bun-binary" : isBundledNode ? "bundled-node" : "unbundled";
|
||||
return resolveCodemodeWorkerSpecifier(runtime, import.meta.url);
|
||||
const specifier = resolveCodemodeWorkerSpecifier(runtime, import.meta.url);
|
||||
if (runtime !== "bundled-node" || !(specifier instanceof URL)) return specifier;
|
||||
// Spawn workers from an in-memory copy. An update replaces or deletes the file while this
|
||||
// process keeps running (#10439). The bundle build keeps the worker free of relative imports
|
||||
// and import.meta, so it runs from a data: URL.
|
||||
codemodeWorkerDataUrl ??= new URL(`data:text/javascript;base64,${readFileSync(specifier).toString("base64")}`);
|
||||
return codemodeWorkerDataUrl;
|
||||
}
|
||||
|
||||
export type InstallChange = { kind: "updated"; version: string } | { kind: "removed" };
|
||||
|
||||
/**
|
||||
* Detect that the package this process runs from changed on disk, for example after `pi update`
|
||||
* in another terminal. Code loaded on demand can then be missing or from another version.
|
||||
*
|
||||
* Checks the package.json read at startup. Resolving it again would walk up past a deleted install
|
||||
* and could find an unrelated package.json, such as one in the home directory.
|
||||
*/
|
||||
export function detectInstallChange(packageJsonPath = startupPackageJsonPath): InstallChange | undefined {
|
||||
// The Bun binary embeds its code, so replacing the executable does not affect this process.
|
||||
if (isBunBinary || !packageJsonPath) return undefined;
|
||||
let installed: PackageJson;
|
||||
try {
|
||||
installed = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as PackageJson;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "removed" } : undefined;
|
||||
}
|
||||
return installed.version && installed.version !== VERSION
|
||||
? { kind: "updated", version: installed.version }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -528,8 +563,12 @@ interface PackageJson {
|
|||
}
|
||||
|
||||
let pkg: PackageJson = {};
|
||||
/** The package.json this process started from, if one existed. */
|
||||
let startupPackageJsonPath: string | undefined;
|
||||
try {
|
||||
pkg = JSON.parse(stripBom(readFileSync(getPackageJsonPath(), "utf-8"))) as PackageJson;
|
||||
const packageJsonPath = getPackageJsonPath();
|
||||
pkg = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as PackageJson;
|
||||
startupPackageJsonPath = packageJsonPath;
|
||||
} catch (e: unknown) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") throw e;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import {
|
|||
APP_NAME,
|
||||
APP_TITLE,
|
||||
CONFIG_DIR_NAME,
|
||||
detectInstallChange,
|
||||
getAgentDir,
|
||||
getAuthPath,
|
||||
getDebugLogPath,
|
||||
|
|
@ -545,6 +546,7 @@ export class InteractiveMode {
|
|||
|
||||
/** The `/bug` hint is shown at most once per session so error output stays readable. */
|
||||
private bugReportHintShown = false;
|
||||
private installChangeWarningShown = false;
|
||||
|
||||
// Extension UI state
|
||||
private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
|
||||
|
|
@ -2168,9 +2170,32 @@ export class InteractiveMode {
|
|||
private maybeSuggestBugReport(message: AssistantMessage): void {
|
||||
if (message.stopReason !== "error" || isRetryableAssistantError(message)) return;
|
||||
if (/\b(?:abort(?:ed)?|cancel(?:l?ed)?)\b/i.test(message.errorMessage ?? "")) return;
|
||||
if (this.maybeShowInstallChangeWarning()) return;
|
||||
this.suggestBugReport();
|
||||
}
|
||||
|
||||
/**
|
||||
* After an error, check whether an update replaced or removed this install while the session ran.
|
||||
* Code loaded on demand then fails with missing modules until restart (#10439). Returns true when
|
||||
* the install changed.
|
||||
*/
|
||||
private maybeShowInstallChangeWarning(): boolean {
|
||||
if (this.installChangeWarningShown) return true;
|
||||
const change = detectInstallChange();
|
||||
if (!change) return false;
|
||||
this.installChangeWarningShown = true;
|
||||
const cause =
|
||||
change.kind === "updated"
|
||||
? `${APP_NAME} was updated to ${change.version} while this session was running (${VERSION})`
|
||||
: `The ${APP_NAME} installation this session runs from was removed or replaced`;
|
||||
const resumeCommand = formatResumeCommand(this.sessionManager);
|
||||
const restart = resumeCommand
|
||||
? `Restart with \`${resumeCommand}\` to continue this session.`
|
||||
: `Restart ${APP_NAME}.`;
|
||||
this.showWarning(`${cause}. Features that load code on demand can fail until restart. ${restart}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private renderCurrentSessionState(): void {
|
||||
this.loadedResourcesContainer.clear();
|
||||
this.chatContainer.clear();
|
||||
|
|
@ -3590,6 +3615,7 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
case "tool_execution_end": {
|
||||
if (event.isError) this.maybeShowInstallChangeWarning();
|
||||
const component = this.pendingTools.get(event.toolCallId);
|
||||
if (component) {
|
||||
component.updateResult({ ...event.result, isError: event.isError });
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { tmpdir } from "os";
|
|||
import { delimiter, join } from "path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
detectInstallChange,
|
||||
detectInstallMethod,
|
||||
findNodePackageDir,
|
||||
getSelfUpdateCommand,
|
||||
|
|
@ -449,3 +450,15 @@ describe("detectInstallMethod", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallChange", () => {
|
||||
// Regression test for #10439: a deleted pnpm install must not fall back to a package.json further up.
|
||||
test("reports a removed install instead of reading a package.json further up", () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-install-change-"));
|
||||
const installDir = join(tempDir, "global", "hash");
|
||||
mkdirSync(installDir, { recursive: true });
|
||||
writeFileSync(join(tempDir, "package.json"), JSON.stringify({ version: "0.0.1" }));
|
||||
rmSync(installDir, { recursive: true, force: true });
|
||||
expect(detectInstallChange(join(installDir, "package.json"))).toEqual({ kind: "removed" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -186,7 +186,6 @@ if (dirname(bedrockLoaderOutput) !== dirname(oauthLoaderOutput)) {
|
|||
const lazyEntryPoints = {
|
||||
anthropic: join(aiDistDir, "auth", "oauth", "anthropic.js"),
|
||||
"bedrock-converse-stream": join(aiDistDir, "api", "bedrock-converse-stream.js"),
|
||||
"codemode-worker": join(codingAgentDistDir, "extensions", "codemode", "worker.js"),
|
||||
"github-copilot": join(aiDistDir, "auth", "oauth", "github-copilot.js"),
|
||||
"image-resize-worker": join(codingAgentDistDir, "utils", "image-resize-worker.js"),
|
||||
"kimi-coding": join(aiDistDir, "auth", "oauth", "kimi-coding.js"),
|
||||
|
|
@ -215,6 +214,30 @@ const lazyResult = await build({
|
|||
splitting: false,
|
||||
});
|
||||
|
||||
// getCodemodeWorkerSpecifier() in config.ts spawns the codemode worker from an in-memory data: URL
|
||||
// so codemode survives an update that replaces or deletes the install (#10439). A data: URL module
|
||||
// has no file location, so the worker must not use the createRequire(import.meta.url) banner,
|
||||
// require(), or imports other than Node builtins.
|
||||
const codemodeWorkerResult = await build({
|
||||
...commonBuildOptions(),
|
||||
banner: undefined,
|
||||
entryNames: "[name]",
|
||||
entryPoints: { "codemode-worker": join(codingAgentDistDir, "extensions", "codemode", "worker.js") },
|
||||
outdir: dirname(bedrockLoaderOutput),
|
||||
splitting: false,
|
||||
});
|
||||
for (const [outputPath, output] of Object.entries(codemodeWorkerResult.metafile.outputs)) {
|
||||
const invalid = output.imports.filter((imported) => imported.kind !== "import-statement" || !isBuiltin(imported.path));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Codemode worker must only import Node builtins: ${invalid.map((imported) => imported.path).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (readFileSync(resolve(repoRoot, outputPath), "utf8").includes("import.meta")) {
|
||||
throw new Error("Codemode worker must not use import.meta");
|
||||
}
|
||||
}
|
||||
|
||||
const imageResizeWorkerOutput = resolve(dirname(bedrockLoaderOutput), "image-resize-worker.js");
|
||||
if (dirname(imageResizeOutput) !== dirname(imageResizeWorkerOutput)) {
|
||||
throw new Error("Image resize implementation and worker were emitted into different directories");
|
||||
|
|
@ -224,7 +247,8 @@ if (dirname(configOutput) !== dirname(bedrockLoaderOutput)) {
|
|||
throw new Error("config.ts and the codemode worker were emitted into different directories");
|
||||
}
|
||||
|
||||
validateExternalImports([mainResult.metafile, lazyResult.metafile]);
|
||||
const metafiles = [mainResult.metafile, lazyResult.metafile, codemodeWorkerResult.metafile];
|
||||
validateExternalImports(metafiles);
|
||||
const cliLauncher = `#!/usr/bin/env node
|
||||
import { createRequire, enableCompileCache } from "node:module";
|
||||
|
||||
|
|
@ -235,7 +259,6 @@ writeFileSync(join(bundleDir, "cli.js"), cliLauncher);
|
|||
chmodSync(join(bundleDir, "cli.js"), 0o755);
|
||||
chmodSync(join(bundleDir, "rpc-entry.js"), 0o755);
|
||||
|
||||
const files =
|
||||
new Set([...Object.keys(mainResult.metafile.outputs), ...Object.keys(lazyResult.metafile.outputs)]).size + 1;
|
||||
const mib = (outputBytes([mainResult.metafile, lazyResult.metafile]) + cliLauncher.length) / (1024 * 1024);
|
||||
const files = new Set(metafiles.flatMap((metafile) => Object.keys(metafile.outputs))).size + 1;
|
||||
const mib = (outputBytes(metafiles) + cliLauncher.length) / (1024 * 1024);
|
||||
console.log(`Built ${relative(repoRoot, bundleDir)} (${files} files, ${mib.toFixed(1)} MiB)`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue