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
2 changes: 1 addition & 1 deletion .github/workflows/standalone-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jobs:

- name: Test extracted application without JavaScript runtimes on PATH
working-directory: ${{ runner.temp }}/standalone-source/packages/coding-agent
run: npx tsx ../../node_modules/vitest/dist/cli.js --run test/compiled-artifact.test.ts test/native-installer.test.ts
run: npx tsx ../../node_modules/vitest/dist/cli.js --run test/compiled-artifact.test.ts test/native-installer.test.ts test/native-probe-timeout.test.ts

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
Expand Down
275 changes: 264 additions & 11 deletions install.sh
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/coding-agent/.changes/eng-6047-native-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- Added verified updates and offline rollback for compiled Prime Agent installations, preserving sessions and restarting with the activated release.
- Fixed normal interruptions during rollback losing the release needed to undo that rollback.
- Fixed malformed compiled-release metadata causing unnecessary npm reinstalls and daemon restarts.
- Fixed interrupted compiled activation recovering the exact rollback target before another lifecycle change.
- Added conservative cleanup for abandoned installer staging and inactive managed releases while retaining live or uncertain releases.
- Fixed direct and planned rollback rejecting inconsistent release metadata, assets, paths, and executable versions before activation.
- Fixed failed activation recovery discarding the state needed to retry restoring the previous release.
- Fixed damaged compiled installations blocking repair and rollback to a healthy retained release.
- Fixed rollback planning after interrupted activation and provided repair guidance for older retained installers without recovery support.
- Fixed stalled executable checks holding the installer lock indefinitely during installation, rollback, or activation recovery.
- Fixed update guidance directing repairable compiled installations to a manual download instead of the update command.
28 changes: 24 additions & 4 deletions packages/coding-agent/docs/standalone-binaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ Pinning a release whose checksum inventory only advertises npm packages uses the

Set `PRIME_AGENT_INSTALL_METHOD=node` to explicitly keep the Node installation, or `binary` to require the compiled application. `PRIME_AGENT_INSTALL_DIR` overrides the managed root (default `$XDG_DATA_HOME/prime-agent` or `~/.local/share/prime-agent`); `PRIME_AGENT_BIN_DIR` overrides the public command directory (default `~/.local/bin`). Both must be absolute. Existing unrelated commands are never replaced. `PRIME_AGENT_INSTALL_LINK=0` installs without a public link.

Each release keeps its executable and assets together under `releases/`. The stable `bin/prime-agent` link changes only after validation, and `bin/previous` retains the earlier release. The installer serializes changes with `.install-lock`; normal interruption cleans up the lock. After a forced kill, confirm its recorded process is no longer running before removing the stale lock. User data remains in `~/.prime/agent`.
Each release keeps its executable and assets together under `releases/`. The stable `bin/prime-agent` link changes only after validation, and `bin/previous` retains the earlier release. The installer serializes changes with `.install-lock`; normal interruption cleans up the lock. Activation also records a durable `.activation-state` intent before changing either launcher. After a forced kill, confirm the recorded installer process is no longer running before removing the stale lock; the next installer or rollback invocation then completes or clears an unambiguous interrupted activation before making another change. User data remains in `~/.prime/agent`.

Reinstalling the same archive creates a fresh release directory with a unique suffix, so it can repair missing or changed assets without modifying files used by existing processes. Old release directories are retained; there is no automatic garbage collection yet.
Reinstalling the same archive creates a fresh release directory with a unique suffix, so it can repair missing or changed assets without modifying files used by existing processes. Cleanup runs only after the replacement is healthy.

Activation replaces the current launcher before refreshing the previous launcher. Normal interruption finishes retaining the replaced release during cleanup. A forced kill between those operations keeps the earlier rollback target intact; it may therefore point to an older retained release rather than the release that was just replaced. A forced kill still requires confirming and clearing the stale installation lock.
After a healthy activation or activation recovery, the installer removes abandoned `.install.<suffix>` staging directories while holding the installation lock. It also removes obsolete managed release directories when `lsof` can inspect the release executable and reports it unused. Current, previous, live, malformed, symlinked, and otherwise uncertain directories are retained. If `lsof` is unavailable or cannot inspect a candidate, that release is kept for a later installer run or manual review; no user-data or legacy Node/npm directory is part of this maintenance.

The installer still shows download and verification progress and can prepare Python. Compilation removes JavaScript dependency installation; Python and external tools still need preparation. Set `PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL=0` to defer Python setup.

Expand All @@ -100,4 +100,24 @@ Migration reuses an equal or newer managed release. It also checks the captured

Before reusing a compiled release, the bridge checks the installer's OS/architecture compatibility result and probes the executable's version. Unsupported hosts quietly retain Node. A broken probe or a mismatch with installed release metadata reports a rate-limited diagnostic with reinstall and opt-out guidance. Command handoff captures the npm link and creates the native link exclusively, so a concurrent npm command wins instead of being overwritten. The public path can be briefly absent during this one-time transfer. If abrupt termination prevents restoration, the captured command remains under the adjacent `.prime-agent-link-*` directory for recovery; the versioned application and user data remain intact.

Compiled self-update and rollback are handled by the final layer of the rollout. Homebrew packaging remains separate work.
Failed automatic migrations retry after 24 hours; `PRIME_AGENT_MIGRATE_RETRY=1` retries immediately. Homebrew packaging remains separate work.

## Updates and rollback

Run `prime-agent update` or `/update` to install the latest version on the current stable or beta channel. Managed compiled installations require a matching platform entry in the release manifest and verify its SHA-256 against both the release checksums and downloaded archive. Failed downloads or validation leave the current executable and assets active. Unmanaged archives must be updated through their original installer.

Updates retain the previous release, preserve user configuration and sessions, and use the existing busy-session confirmation and daemon restart coordination. Relaunches resolve the stable launcher after activation, so the new process runs the updated application. The installer checks that the active release has not changed since the update was planned and serializes activation with its installation lock.

Run `prime-agent update --rollback` or `/update --rollback` to restore the previous local release without downloading anything. Its managed path, checksum marker, package version, install source, required assets, executable version, and help probe are validated before switching. These checks establish internal installation consistency; they do not cryptographically re-verify every installed file against the original archive. A second rollback restores the release you just left when that release remains healthy. Rollback requires a retained release and applies only to managed compiled installations. `--force` permits reinstalling the version selected by the release channel.

If the active release is damaged, rollback validates and activates the healthy previous release without retaining the damaged one as a rollback target. Update planning can use the intact previous release's installer when it declares support for damaged-release recovery. Older retained installers instead give published-installer recovery guidance. If the current executable cannot start, rerun the published installer with `PRIME_AGENT_INSTALL_DIR` set to the managed root. Repairs preserve an existing healthy previous release.

Normal interruption during rollback finishes retaining the release being left. A forced kill can leave the launcher changes incomplete and the installation lock stale, but it also leaves the activation intent needed for the next installer invocation to recover the exact pair. Recovery refuses an unexpected or invalid link state and reports the state file instead of guessing.

## Coverage and recovery limits

Native CI runs the extracted archives on macOS 15 (ARM64 and x64) and Ubuntu 24.04 (ARM64 and x64). It tests installation, forced reinstall, later update, offline rollback, daemon replacement, runtime assets, RPC, and managed Python without JavaScript runtimes on the application PATH. The installer selects macOS 13+ and compatible glibc Linux, but these selection checks do not constitute execution testing on every older OS release.

Focused regressions cover migration without lifecycle scripts, incompatible existing binaries, deferred migration during internal daemon startup, competing installs and npm command handoffs, checksum/manifest failures, rollback metadata and executable-version mismatches, interrupted activation recovery, conservative release retention, and orphan staging cleanup. Normal installer hangups release the lock. A forced kill still requires confirming and clearing its stale lock before recovery can run; complete filesystem power-loss, disk-exhaustion, ACL, and network-filesystem fault testing is outside this matrix.

Rollback restores application files, not arbitrary future user-data schema changes. SHA-256 verification detects archive/metadata disagreement; it does not introduce an independent release-signing authority. Shell aliases and earlier competing PATH entries are not rewritten. The Node fallback package remains on disk, as do compiled releases whose use cannot be ruled out safely.
2 changes: 1 addition & 1 deletion packages/coding-agent/src/cli/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
},
{
path: ["update"],
usage: "update [--force]",
usage: "update [--force] [--rollback]",
summary: "Update Prime Agent",
},
{
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/src/cli/daemon-update-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
DAEMON_WORKER_TOKEN_ENV,
} from "../modes/daemon/daemon-worker-protocol.js";
import { isProcessAlive, spawnHidden } from "../utils/child-process.js";
import { createCliSubprocessLaunchSpec } from "./subprocess-launch.js";
import { createUpdatedCliSubprocessLaunchSpec } from "./subprocess-launch.js";

export const DAEMON_UPDATE_RESTART_COORDINATOR_FLAG = "--internal-update-restart-coordinator";
export const DAEMON_UPDATE_RESTART_STATUS_FLAG = "--internal-update-restart-status";
Expand Down Expand Up @@ -538,7 +538,7 @@ export async function launchDaemonUpdateRestartCoordinator(
const statusPath = createStatusPath(agentDir, socketPath, requestId);
const inheritedOrigin = process.env[DAEMON_WORKER_ACTIVE_SESSION_ID_ENV];
const originActiveSessionId = options.originActiveSessionId ?? inheritedOrigin;
const launch = createCliSubprocessLaunchSpec([
const launch = createUpdatedCliSubprocessLaunchSpec([
"update",
DAEMON_UPDATE_RESTART_COORDINATOR_FLAG,
"--daemon-socket",
Expand Down
102 changes: 102 additions & 0 deletions packages/coding-agent/src/cli/native-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { execFileSync } from "node:child_process";
import { accessSync, constants, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { APP_NAME, type SelfUpdateCommand } from "../config.js";
import {
getNativeInstallationTarget,
readNativeInstallation,
readNativeRollbackInstallation,
} from "../utils/native-installation.js";
import { getLatestPiRelease, isNewerPackageVersion } from "../utils/version-check.js";

export interface NativeUpdatePlan {
command?: SelfUpdateCommand;
targetVersion: string;
}

export async function getNativeUpdatePlan(options: {
force: boolean;
rollback: boolean;
executable?: string;
}): Promise<NativeUpdatePlan> {
const current = getNativeInstallationTarget(options.executable);
if (!current)
throw new Error(
"This compiled application is not owned by the Prime Agent installer. Update it using its original installer.",
);
Comment thread
kevinjosethomas marked this conversation as resolved.
const active = readNativeInstallation(current.root);
const installation = active ?? readNativeInstallation(current.root, "previous");
if (!installation || installation.platform !== current.platform)
throw new Error("The compiled installation is damaged. Run the published installer again to repair it.");
if (
(options.rollback || !active) &&
!/^# prime-agent-native-recovery-v1$/m.test(readFileSync(join(installation.releaseDir, "install.sh"), "utf8"))
)
throw new Error(
"The retained installer does not support this recovery. Run the published installer at https://app.primeintellect.ai/prime-agent/install.sh again to repair it.",
);
accessSync(installation.root, constants.W_OK);
accessSync(join(installation.root, "bin"), constants.W_OK);
let version: string;
let checksum: string | undefined;
let previousTarget: string | undefined;
const baseUrl = process.env.PRIME_AGENT_DOWNLOAD_BASE_URL?.trim() || installation.baseUrl;
if (options.rollback) {
const previous = readNativeRollbackInstallation(installation.root);
if (!previous || previous.executable === current.executable)
throw new Error("No valid previous compiled release is available.");
let reportedVersion: string;
try {
reportedVersion = execFileSync(previous.executable, ["--version"], {
encoding: "utf8",
timeout: 10000,
});
} catch {
throw new Error("The previous compiled release executable could not be validated.");
}
if (reportedVersion !== previous.version && reportedVersion !== `${previous.version}\n`)
throw new Error("The previous compiled release executable reports a different version.");
try {
execFileSync(previous.executable, ["--help"], { stdio: "ignore", timeout: 10000 });
} catch {
throw new Error("The previous compiled release executable failed its help probe.");
}
version = previous.version;
previousTarget = relative(join(installation.root, "bin"), previous.executable);
Comment thread
kevinjosethomas marked this conversation as resolved.
} else {
const release = await getLatestPiRelease(current.version, { baseUrl });
if (!release || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(release.version))
throw new Error("Could not resolve a compiled release. The installed version was kept.");
if (active && !options.force && !isNewerPackageVersion(release.version, current.version))
return { targetVersion: current.version };
const artifact = release.binaries?.find((entry) => entry.platform === current.platform);
if (!artifact) throw new Error(`No verified compiled archive is available for ${current.platform}.`);
version = release.version;
checksum = artifact.sha256;
}
const environment = {
PRIME_AGENT_INSTALL_METHOD: "binary",
PRIME_AGENT_INSTALL_DIR: installation.root,
PRIME_AGENT_DOWNLOAD_BASE_URL: baseUrl,
PRIME_AGENT_INSTALL_LINK: "0",
PRIME_AGENT_INSTALLER_NONINTERACTIVE: "1",
PRIME_AGENT_INSTALLER_PLAIN: "1",
PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL: "0",
PRIME_AGENT_EXPECTED_CURRENT: relative(join(current.root, "bin"), current.executable),
...(previousTarget ? { PRIME_AGENT_EXPECTED_PREVIOUS: previousTarget } : {}),
...(checksum ? { PRIME_AGENT_EXPECTED_SHA256: checksum } : {}),
};
return {
targetVersion: version,
command: {
command: "/usr/bin/env",
args: [
...Object.entries(environment).map(([name, value]) => `${name}=${value}`),
"sh",
join(installation.releaseDir, "install.sh"),
options.rollback ? "--rollback" : version,
],
display: `${APP_NAME} update${options.rollback ? " --rollback" : options.force ? " --force" : ""}`,
},
};
}
2 changes: 1 addition & 1 deletion packages/coding-agent/src/cli/public-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ async function runPublicCommand(args: string[]): Promise<PublicCommandResult> {
if (hasLegacyPackageTarget) {
return fail("Package updates moved to the package command.", `Use "${APP_NAME} package update [source]".`);
}
const options = parseBooleanOptions(rest, new Set(["--force"]), "update");
const options = parseBooleanOptions(rest, new Set(["--force", "--rollback"]), "update");
if (!options) return HANDLED;
await handlePackageCommand(["update", "--self", ...options]);
return HANDLED;
Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/src/cli/subprocess-launch.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { existsSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { isBunBinary } from "../config.js";
import { getNativeInstallation } from "../utils/native-installation.js";

export interface CliSubprocessLaunchSpec {
command: string;
args: string[];
}

export function createUpdatedCliSubprocessLaunchSpec(args: readonly string[]): CliSubprocessLaunchSpec {
const native = isBunBinary ? getNativeInstallation() : undefined;
return native ? { command: native.launcher, args: [...args] } : createCliSubprocessLaunchSpec(args);
}

export function createCliSubprocessEnv(
source: NodeJS.ProcessEnv = process.env,
entrypoint = process.argv[1],
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { basename, dirname, join, posix, resolve, sep, win32 } from "path";
import { fileURLToPath } from "url";
import { shouldUseWindowsShell, spawnSyncHidden } from "./utils/child-process.js";
import { normalizeSocketPath } from "./utils/daemon-socket-path.js";
import { getNativeInstallationTarget } from "./utils/native-installation.js";

// =============================================================================
// Package Detection
Expand Down Expand Up @@ -343,6 +344,7 @@ export function getSelfUpdateUnavailableInstruction(
}

export function getUpdateInstruction(packageName: string): string {
if (isBunBinary && getNativeInstallationTarget()) return `Run: ${APP_NAME} update`;
const method = detectInstallMethod();
const command = getSelfUpdateCommandForMethod(method, packageName);
if (command) {
Expand Down
23 changes: 12 additions & 11 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ import {
launchDaemonUpdateRestartCoordinator,
resolveDaemonUpdateRestartSocketPath,
} from "../../cli/daemon-update-restart.js";
import { type CliSubprocessLaunchSpec, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js";
import {
type CliSubprocessLaunchSpec,
createCliSubprocessLaunchSpec,
createUpdatedCliSubprocessLaunchSpec,
} from "../../cli/subprocess-launch.js";
import {
APP_NAME,
APP_TITLE,
Expand Down Expand Up @@ -8660,15 +8664,12 @@ export class InteractiveMode {
this.ui.stop();

const updateEnv = includesSelf ? { ...process.env, [SELF_UPDATE_INTERACTIVE_CHILD_ENV]: "1" } : process.env;
const updateResult = spawnSync(
process.execPath,
[...process.execArgv, entrypoint, "update", ...updateChildArgs],
{
stdio: "inherit",
cwd: updateCwd,
env: updateEnv,
},
);
const updateLaunch = createCliSubprocessLaunchSpec(["update", ...updateChildArgs]);
const updateResult = spawnSync(updateLaunch.command, updateLaunch.args, {
stdio: "inherit",
cwd: updateCwd,
env: updateEnv,
});
const updateExitCode = updateResult.status ?? (updateResult.signal ? 1 : 0);
const selfUpdateNotAttempted =
includesSelf && !updateResult.error && updateExitCode === SELF_UPDATE_NOT_ATTEMPTED_EXIT_CODE;
Expand Down Expand Up @@ -8714,7 +8715,7 @@ export class InteractiveMode {
);
}
}
const relaunch = createCliSubprocessLaunchSpec(relaunchArgs);
const relaunch = createUpdatedCliSubprocessLaunchSpec(relaunchArgs);
const updateProcess = process as NodeJS.Process & { execve?: UpdateRelaunchExecve };
try {
if (
Expand Down
Loading
Loading