From 4bf122bb6fd3455b09511791a16a00022098f8a7 Mon Sep 17 00:00:00 2001 From: kt Date: Mon, 14 Sep 2026 15:02:33 -0700 Subject: [PATCH] add a persistent update channel with a /nightly command (#2323) * add a persistent update channel with a /beta command Self-updates read latest.json or beta.json based on the running version's prerelease tag, with no way to choose. This adds an updateChannel setting, --beta/--stable flags on prime-agent update that persist it, a /beta slash command that switches to beta and updates immediately (/beta off returns to stable, /beta status reports the choice), and routes startup version notices and compiled-install updates through the same preference. * apply formatter output from pre-commit check * warn and confirm before switching updates to the beta channel /beta no longer flips the setting itself. The update command prints that beta builds are unreleased and may be broken, asks for confirmation unless --force is given, persists the channel only after a yes, and then continues into the existing busy-session confirmation. * let a channel switch install the current beta and read the channel from global settings A stable 1.2.3 never saw 1.2.3-beta.N as an update because prerelease ordering ranks it lower. isReleaseUpdateCandidate keeps same-channel updates strictly newer but accepts a different version with a base version that is not older when the user explicitly switched channels. The CLI, compiled updates, and startup notices use it. getUpdateChannel now reads global settings only, so a project settings file cannot override the user's choice, and ignores values other than stable or beta. * gate the beta switch before any update work and never persist it on cancel The public update command now accepts --beta and --stable. The beta warning and confirmation run before extensions are touched, the channel is persisted only after the busy-session confirmation succeeds or when nothing needs installing, and a beta manifest that cannot be resolved aborts instead of falling back to the stable registry package. Cancelled or refused updates exit with the not-attempted code when launched from the interactive client, so /beta declined is a no-op rather than a relaunch. * treat an unresolved beta manifest as a no-op for compiled installs and reject channel flags on extension-only updates getNativeUpdatePlan now throws NativeReleaseUnavailableError when the manifest cannot be resolved, and the self-update plan maps that to the same non-runnable beta-unavailable result the npm path uses, so /beta does not relaunch the client when nothing was installed. --beta and --stable are rejected for extension-only targets rather than being accepted and ignored, mirroring --rollback. * keep exit code 1 when the session-loss prompt is declined The interactive parent relies on a non-sentinel exit there, and an existing test pins it. The not-attempted sentinel stays for the beta-specific cancellations: the beta warning declined, the non-TTY refusal, and an unresolvable beta manifest. * map every failed compiled-release lookup to the unavailable plan and save a confirmed channel even without an updater Network, timeout, and malformed-manifest errors from the compiled release lookup now surface as NativeReleaseUnavailableError with the cause attached, so a /beta run on a compiled install is a no-op instead of a failed update that relaunches the client. When no self-update command exists for the install, the already-confirmed channel switch is still persisted before the manual-update guidance is printed. * benchmarks: clamp CPU deltas at zero when a child exits mid-measurement cpu_total() sums CPU time over the live process tree, so a process exiting between the start and end samples lowers the total and the raw delta can go slightly negative. The report schema rejects negative observations, which aborted the agents_view trial on the main baseline and left 19 measurements incomplete. cpu_delta() clamps at zero; all ten sites use it. * list the channel flags in the public update help The public command registry renders its own usage for prime-agent update --help; it still advertised only --force and --rollback. * rename the channel surface to nightly /nightly, --nightly, the saved updateChannel value, and every message now say nightly. The release bucket still publishes these builds as beta (beta.json and -beta.N version tags), so that mapping stays internal to version-check. * never install a lower base version, even with --force --force is also how a channel switch is scripted without a TTY, so it must not double as permission to downgrade. Both the npm and compiled update plans now refuse a release whose major.minor.patch is below the installed version and leave the channel unchanged; compiled installs are pointed at --rollback. * report being ahead of a channel as nothing to update A channel whose current release is below the installed base version is still never installed, but without --force that is now a green no-op with the not-attempted exit code, so a plain update or /nightly off while ahead does not fail or relaunch the client. With --force the refusal stays explicit. The compiled path returns the refusal in its plan instead of throwing, so the interactive client treats it the same way. * guard a missing nightly manifest by the effective channel A -beta install with no saved preference is on the nightly channel by inference, so an unresolvable manifest must abort for it too instead of falling through to the stable registry package. * reload settings after the updater child returns without relaunching /nightly persists the channel inside the update child. When that child installs nothing, the interactive parent keeps running with its in-memory settings, so /nightly status could report the old channel. Reload from disk before reporting. * say what was not updated instead of claiming nothing was installed With an all target, extensions may already have been updated before the nightly manifest lookup fails or a downgrade is refused. Both messages now state that Prime Agent itself was not updated. * keep a successful extension update when the self update is unavailable For an all target the extensions are updated before the self update is planned. A missing nightly manifest or refused downgrade now reports that Prime Agent was not updated without failing the run that already updated extensions. --------- Co-authored-by: Seth Karten <32787133+sethkarten@users.noreply.github.com> --- .../.changes/update-nightly-channel.md | 1 + .../coding-agent/docs/standalone-binaries.md | 2 +- .../coding-agent/src/cli/command-registry.ts | 8 +- .../coding-agent/src/cli/native-update.ts | 37 +++- .../coding-agent/src/cli/public-command.ts | 6 +- .../coding-agent/src/core/settings-manager.ts | 13 ++ .../coding-agent/src/core/slash-commands.ts | 8 +- .../src/modes/interactive/interactive-mode.ts | 41 +++- .../src/modes/shared/startup-notices.ts | 2 +- .../coding-agent/src/package-manager-cli.ts | 154 +++++++++++++-- .../coding-agent/src/utils/version-check.ts | 64 +++++- .../test/interactive-mode-startup.test.ts | 4 +- .../test/package-command-paths.test.ts | 184 +++++++++++++++++- .../coding-agent/test/version-check.test.ts | 78 ++++++++ scripts/benchmarks/tests/test_ui.py | 10 + scripts/benchmarks/ui.py | 31 ++- 16 files changed, 598 insertions(+), 45 deletions(-) create mode 100644 packages/coding-agent/.changes/update-nightly-channel.md diff --git a/packages/coding-agent/.changes/update-nightly-channel.md b/packages/coding-agent/.changes/update-nightly-channel.md new file mode 100644 index 000000000..57a8fef9a --- /dev/null +++ b/packages/coding-agent/.changes/update-nightly-channel.md @@ -0,0 +1 @@ +- Added a persistent update channel. `/nightly` (or `prime-agent update --nightly`) warns that nightly builds may be broken, asks for confirmation, then switches self-updates to the nightly (`beta.json`) release manifest and runs the normal update flow with its busy-session confirmation. `/nightly off` or `--stable` returns to stable. Startup version notices follow the chosen channel. diff --git a/packages/coding-agent/docs/standalone-binaries.md b/packages/coding-agent/docs/standalone-binaries.md index cd3e47917..99eda3991 100644 --- a/packages/coding-agent/docs/standalone-binaries.md +++ b/packages/coding-agent/docs/standalone-binaries.md @@ -104,7 +104,7 @@ Failed automatic migrations retry after 24 hours; `PRIME_AGENT_MIGRATE_RETRY=1` ## 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. +Run `prime-agent update` or `/update` to install the latest version on the current stable or beta channel. The channel follows the running version unless you choose one: `/nightly` (or `prime-agent update --nightly`) warns that nightly builds are unreleased and may be broken, asks for confirmation, then switches to the nightly channel and runs the update with the usual busy-session confirmation, `/nightly off` (or `--stable`) returns to stable, and `/nightly status` shows the current choice. Nightly builds are the `-beta` prereleases published in `beta.json`. The channel is stored only after you confirm, and only after the busy-session confirmation, so declining either leaves settings untouched. If the nightly manifest cannot be resolved, nothing is installed and the channel is not changed. The preference is per user and cannot be overridden by project settings; later `/update` runs keep following it. Updates never move to a lower base version. If the channel's current release is behind what you have installed, `update` reports that you are ahead and installs nothing; with `--force` it refuses explicitly. Compiled installs can use `--rollback` to go back to the retained previous release. 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. diff --git a/packages/coding-agent/src/cli/command-registry.ts b/packages/coding-agent/src/cli/command-registry.ts index 706f03ffb..f160929ae 100644 --- a/packages/coding-agent/src/cli/command-registry.ts +++ b/packages/coding-agent/src/cli/command-registry.ts @@ -149,8 +149,14 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [ }, { path: ["update"], - usage: "update [--force] [--rollback]", + usage: "update [--force] [--rollback] [--nightly|--stable]", summary: "Update Prime Agent", + options: [ + "--force Reinstall even if the current version is the latest on the channel", + "--rollback Restore the previous compiled release", + "--nightly Switch updates to the nightly channel (unreleased builds, may be broken)", + "--stable Return updates to the stable channel", + ], }, { path: ["model"], diff --git a/packages/coding-agent/src/cli/native-update.ts b/packages/coding-agent/src/cli/native-update.ts index 1e32f6a6c..32d7e7575 100644 --- a/packages/coding-agent/src/cli/native-update.ts +++ b/packages/coding-agent/src/cli/native-update.ts @@ -7,16 +7,37 @@ import { readNativeInstallation, readNativeRollbackInstallation, } from "../utils/native-installation.js"; -import { getLatestPiRelease, isNewerPackageVersion } from "../utils/version-check.js"; +import { + getLatestPiRelease, + isBaseVersionDowngrade, + isReleaseUpdateCandidate, + type UpdateChannel, +} from "../utils/version-check.js"; + +/** The release manifest for the requested channel could not be resolved; the installed version was kept. */ +export class NativeReleaseUnavailableError extends Error { + constructor(cause?: unknown) { + super( + cause instanceof Error + ? `Could not resolve a compiled release: ${cause.message}. The installed version was kept.` + : "Could not resolve a compiled release. The installed version was kept.", + cause instanceof Error ? { cause } : undefined, + ); + this.name = "NativeReleaseUnavailableError"; + } +} export interface NativeUpdatePlan { command?: SelfUpdateCommand; targetVersion: string; + /** Set when the channel's current release has a lower base version than the installed one; nothing is planned. */ + refusedDowngradeTo?: string; } export async function getNativeUpdatePlan(options: { force: boolean; rollback: boolean; + channel?: UpdateChannel; executable?: string; }): Promise { const current = getNativeInstallationTarget(options.executable); @@ -64,10 +85,18 @@ export async function getNativeUpdatePlan(options: { version = previous.version; previousTarget = relative(join(installation.root, "bin"), previous.executable); } else { - const release = await getLatestPiRelease(current.version, { baseUrl }); + let release: Awaited>; + try { + release = await getLatestPiRelease(current.version, { baseUrl, channel: options.channel }); + } catch (error) { + // Network, timeout, and malformed-manifest failures all mean the same thing here: nothing to install. + throw new NativeReleaseUnavailableError(error); + } 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)) + throw new NativeReleaseUnavailableError(); + if (active && isBaseVersionDowngrade(release.version, current.version)) + return { targetVersion: current.version, refusedDowngradeTo: release.version }; + if (active && !options.force && !isReleaseUpdateCandidate(release.version, current.version, options.channel)) 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}.`); diff --git a/packages/coding-agent/src/cli/public-command.ts b/packages/coding-agent/src/cli/public-command.ts index 9c6e53bd1..252af2a98 100644 --- a/packages/coding-agent/src/cli/public-command.ts +++ b/packages/coding-agent/src/cli/public-command.ts @@ -133,7 +133,11 @@ async function runPublicCommand(args: string[]): Promise { if (hasLegacyPackageTarget) { return fail("Package updates moved to the package command.", `Use "${APP_NAME} package update [source]".`); } - const options = parseBooleanOptions(rest, new Set(["--force", "--rollback"]), "update"); + const options = parseBooleanOptions( + rest, + new Set(["--force", "--rollback", "--nightly", "--stable"]), + "update", + ); if (!options) return HANDLED; await handlePackageCommand(["update", "--self", ...options]); return HANDLED; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 8a20c92a4..56992e2e6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -182,6 +182,7 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; subagentDefaultModel?: string; // "provider/id" for rlm.spawn without a pinned model; unset inherits the parent model + updateChannel?: "stable" | "nightly"; // release channel for self-updates; unset follows the running version recentModels?: string[]; // "provider/id" keys, most-recently-used first // "provider/id" for background LLM passes (refinement review and planning); // unset falls back to the session model. Routing these to a different model @@ -846,6 +847,18 @@ export class SettingsManager { this.save(); } + /** A per-user preference: read from global settings only, and ignore anything but the two known values. */ + getUpdateChannel(): "stable" | "nightly" | undefined { + const channel = this.globalSettings.updateChannel; + return channel === "stable" || channel === "nightly" ? channel : undefined; + } + + setUpdateChannel(channel: "stable" | "nightly"): void { + this.globalSettings.updateChannel = channel; + this.markModified("updateChannel"); + this.save(); + } + getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined { return this.settings.defaultThinkingLevel; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index c1707bd0d..ea56d625b 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -129,7 +129,13 @@ const CANONICAL_BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "update", description: `Update ${APP_NAME} and installed packages`, - argumentHint: "[source|--self|--extensions]", + argumentHint: "[source|--self|--extensions|--nightly|--stable]", + takesArgument: true, + }, + { + name: "nightly", + description: `Switch ${APP_NAME} updates to the nightly channel (unreleased builds, may be broken)`, + argumentHint: "[on|off|status]", takesArgument: true, }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 6c14b2a4f..d78fa3427 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -150,7 +150,7 @@ import { resizeImage } from "../../utils/image-resize.js"; import { getCwdRelativePath } from "../../utils/paths.js"; import { killTrackedDetachedChildren } from "../../utils/shell.js"; import { ensureTool, ensureToolWithStatus, formatMissingRipgrepMessage } from "../../utils/tools-manager.js"; -import { checkForNewPiVersion } from "../../utils/version-check.js"; +import { checkForNewPiVersion, resolveUpdateChannel } from "../../utils/version-check.js"; import type { AgentConnection, AgentConnectionExtensionUiRequest, @@ -1592,7 +1592,9 @@ export class InteractiveMode { // `returnToAgentsView`, which is also set for direct daemon attaches that never // rendered the agents view and still want the in-session fallback.) const ownsGlobalStartupNotices = !this.options.agentsViewOwnsStartupNotices; - const newVersionPromise = ownsGlobalStartupNotices ? checkForNewPiVersion(this.version) : undefined; + const newVersionPromise = ownsGlobalStartupNotices + ? checkForNewPiVersion(this.version, this.settingsManager.getUpdateChannel()) + : undefined; const packageUpdatesPromise = ownsGlobalStartupNotices ? checkForPackageUpdates({ cwd: this.getCurrentCwd(), @@ -4954,6 +4956,37 @@ export class InteractiveMode { await this.handleReloadCommand(); return; } + if (commandName === "nightly") { + this.editor.setText(""); + const nightlyArg = commandArgs?.trim().toLowerCase(); + if (nightlyArg === "status") { + const channel = resolveUpdateChannel(this.version, this.settingsManager.getUpdateChannel()); + const source = this.settingsManager.getUpdateChannel() + ? "set in settings" + : "inferred from the running version"; + this.showStatus(`Updates follow the ${channel} channel (${source}). v${this.version} installed.`); + return; + } + if (nightlyArg === "off" || nightlyArg === "stable") { + this.settingsManager.setUpdateChannel("stable"); + this.showStatus( + "Updates now follow the stable channel. Run /update to install the latest stable release.", + ); + return; + } + if (nightlyArg && nightlyArg !== "on") { + this.showError("Usage: /nightly [on|off|status]"); + return; + } + if (this.isAgentCompacting() || this.isAgentStreaming() || this.isBashRunning()) { + this.showWarning("Wait for the current work to finish before updating."); + return; + } + // The update command owns the nightly warning, the channel switch, and the + // busy-session confirmation, so declining either leaves settings untouched. + await this.handleUpdateCommand("--self --nightly"); + return; + } if (commandName === "update") { this.editor.setText(""); const updateArgs = parseCommandArgs(commandArgs); @@ -9065,6 +9098,10 @@ export class InteractiveMode { } this.ui.requestRender(true); + // The updater ran in a child process and may have persisted settings, for example the + // update channel, without installing anything. Pick those up before reporting. + await this.settingsManager.reload().catch(() => undefined); + if (selfUpdateNotAttempted) { this.showStatus(`Update did not change ${APP_NAME}. Reloading resources...`); await this.handleReloadCommand(); diff --git a/packages/coding-agent/src/modes/shared/startup-notices.ts b/packages/coding-agent/src/modes/shared/startup-notices.ts index 484848ed4..b4c0a44bf 100644 --- a/packages/coding-agent/src/modes/shared/startup-notices.ts +++ b/packages/coding-agent/src/modes/shared/startup-notices.ts @@ -32,7 +32,7 @@ export interface StartupNoticeCheckOptions { /** Run every startup check in parallel and collect the results. */ export async function gatherStartupNotices(options: StartupNoticeCheckOptions): Promise { const [newVersion, packageUpdates, tmuxWarning] = await Promise.all([ - checkForNewPiVersion(options.version), + checkForNewPiVersion(options.version, options.settingsManager.getUpdateChannel()), checkForPackageUpdates(options), checkTmuxKeyboardSetup(), ]); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 98544f6a9..7b7d5aa91 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -12,7 +12,12 @@ import { type RunningDaemonProbe, shutdownConnectedDaemonAndWait, } from "./cli/daemon-launch.js"; -import { confirmDaemonSessionLoss, type DaemonSessionLossCopy, pluralizeSessions } from "./cli/daemon-stop-confirm.js"; +import { + confirmDaemonSessionLoss, + type DaemonSessionLossCopy, + pluralizeSessions, + promptYesNo, +} from "./cli/daemon-stop-confirm.js"; import { acquireDaemonUpdateRestartCoordinator, buildDaemonUpdateRestartReport, @@ -28,7 +33,7 @@ import { launchDaemonUpdateRestartCoordinator, waitForActiveDaemonUpdateRestartCoordinator, } from "./cli/daemon-update-restart.js"; -import { getNativeUpdatePlan } from "./cli/native-update.js"; +import { getNativeUpdatePlan, NativeReleaseUnavailableError } from "./cli/native-update.js"; import { APP_NAME, CONFIG_DIR_NAME, @@ -70,7 +75,13 @@ import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV, } from "./modes/daemon/daemon-worker-protocol.js"; import { shouldUseWindowsShell } from "./utils/child-process.js"; -import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.js"; +import { + getLatestPiRelease, + isBaseVersionDowngrade, + isReleaseUpdateCandidate, + resolveUpdateChannel, + type UpdateChannel, +} from "./utils/version-check.js"; export type PackageCommand = "install" | "remove" | "update" | "list"; @@ -89,6 +100,7 @@ interface PackageCommandOptions { local: boolean; force: boolean; rollback: boolean; + channel?: UpdateChannel; help: boolean; daemonSocketPath?: string; restartCoordinator: boolean; @@ -117,7 +129,7 @@ function getPackageCommandUsage(command: PackageCommand): string { case "remove": return `${APP_NAME} package remove [--local]`; case "update": - return `${APP_NAME} update [--force] or ${APP_NAME} package update [source]`; + return `${APP_NAME} update [--force] [--rollback] [--nightly|--stable] or ${APP_NAME} package update [source]`; case "list": return `${APP_NAME} package list`; } @@ -170,6 +182,8 @@ Options: --extension Update one package only --force Reinstall ${APP_NAME} even if the current version is latest --rollback Restore the previous compiled release + --nightly Switch updates to the nightly channel (unreleased builds, may be broken) + --stable Return updates to the stable channel --daemon-socket Restart the daemon listening on this exact socket Commands: @@ -204,6 +218,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let local = false; let force = false; let rollback = false; + let channel: UpdateChannel | undefined; let help = false; let invalidOption: string | undefined; let invalidArgument: string | undefined; @@ -268,6 +283,19 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--nightly" || arg === "--stable") { + if (command !== "update") { + invalidOption = invalidOption ?? arg; + continue; + } + const requested: UpdateChannel = arg === "--nightly" ? "nightly" : "stable"; + if (channel && channel !== requested) { + conflictingOptions = conflictingOptions ?? "--nightly and --stable cannot be combined"; + } + channel = requested; + continue; + } + if (arg === "--daemon-socket") { if (command !== "update") { invalidOption = invalidOption ?? arg; @@ -379,6 +407,8 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined if (rollback && (extensionsFlag || extensionFlagSource || (source && !isSelfUpdateSource(source)))) conflictingOptions = "--rollback only applies to Prime Agent itself"; + if (channel && (extensionsFlag || extensionFlagSource || (source && !isSelfUpdateSource(source)))) + conflictingOptions = conflictingOptions ?? "--nightly and --stable only apply to Prime Agent itself"; return { command, source, @@ -386,6 +416,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined local, force, rollback, + channel, help, daemonSocketPath, restartCoordinator, @@ -447,6 +478,45 @@ interface SelfUpdatePlan { shouldRun: boolean; targetVersion?: string; command?: SelfUpdateCommand; + /** The requested channel could not be resolved; nothing was installed and nothing should be persisted. */ + unavailable?: boolean; +} + +/** A cancelled or refused self-update: the interactive parent must not relaunch, a shell caller gets a failure. */ +function setSelfUpdateAbortedExitCode(): void { + process.exitCode = process.env[SELF_UPDATE_INTERACTIVE_CHILD_ENV] === "1" ? SELF_UPDATE_NOT_ATTEMPTED_EXIT_CODE : 1; +} + +/** + * The channel's current release has a lower base version than what is installed. Never install + * it. Without --force that is simply "nothing to update"; with --force it is an explicit request + * we refuse, since --force is also how a channel switch is scripted without a TTY. + */ +function behindChannelPlan(latestVersion: string, force: boolean, channel: UpdateChannel | undefined): SelfUpdatePlan { + const plan = { installSpec: PACKAGE_NAME, packageName: PACKAGE_NAME }; + if (force) { + console.error( + chalk.red( + `Refusing to move from v${VERSION} to v${latestVersion}: that is a downgrade, and --force does not override it. ${APP_NAME} was not updated and the update channel was not changed.${isBunBinary ? " Use --rollback to restore the previous compiled release." : ""}`, + ), + ); + return { ...plan, shouldRun: false, unavailable: true }; + } + console.log( + chalk.green( + `${APP_NAME} v${VERSION} is ahead of the ${resolveUpdateChannel(VERSION, channel)} channel (v${latestVersion}); nothing to update.`, + ), + ); + return { ...plan, shouldRun: false }; +} + +function nightlyReleaseUnavailablePlan(): SelfUpdatePlan { + console.error( + chalk.red( + `Could not resolve a nightly release from the release manifest. ${APP_NAME} was not updated and the update channel was not changed.`, + ), + ); + return { installSpec: PACKAGE_NAME, packageName: PACKAGE_NAME, shouldRun: false, unavailable: true }; } function setSelfUpdateNoChangeExitCode(): void { @@ -454,27 +524,42 @@ function setSelfUpdateNoChangeExitCode(): void { process.env[SELF_UPDATE_INTERACTIVE_CHILD_ENV] === "1" ? SELF_UPDATE_NOT_ATTEMPTED_EXIT_CODE : undefined; } -async function getSelfUpdatePlan(force: boolean, rollback = false): Promise { +async function getSelfUpdatePlan(force: boolean, rollback = false, channel?: UpdateChannel): Promise { + // A -beta install with no saved preference is on the nightly channel too; a missing manifest + // must never push it onto the stable registry package. + const effectiveChannel = resolveUpdateChannel(VERSION, channel); if (isBunBinary) { - const plan = await getNativeUpdatePlan({ force, rollback }); - if (!plan.command) console.log(chalk.green(`${APP_NAME} is already up to date (v${plan.targetVersion})`)); - return { installSpec: PACKAGE_NAME, packageName: PACKAGE_NAME, shouldRun: !!plan.command, ...plan }; + try { + const plan = await getNativeUpdatePlan({ force, rollback, channel }); + if (plan.refusedDowngradeTo) return behindChannelPlan(plan.refusedDowngradeTo, force, channel); + if (!plan.command) console.log(chalk.green(`${APP_NAME} is already up to date (v${plan.targetVersion})`)); + return { installSpec: PACKAGE_NAME, packageName: PACKAGE_NAME, shouldRun: !!plan.command, ...plan }; + } catch (error) { + if (effectiveChannel === "nightly" && error instanceof NativeReleaseUnavailableError) + return nightlyReleaseUnavailablePlan(); + throw error; + } } if (rollback) throw new Error("Rollback is only available for managed compiled installations."); try { - const latestRelease = await getLatestPiRelease(VERSION); + const latestRelease = await getLatestPiRelease(VERSION, { channel }); + // The registry default resolves to the stable package, so a missing nightly manifest must not fall through to it. + if (!latestRelease && effectiveChannel === "nightly") return nightlyReleaseUnavailablePlan(); const packageName = latestRelease?.packageName ?? PACKAGE_NAME; const installSpec = latestRelease?.installSpec ?? packageName; const packageRenameRequiresUpdate = !latestRelease?.installSpec && packageName !== PACKAGE_NAME; + if (latestRelease && isBaseVersionDowngrade(latestRelease.version, VERSION)) + return behindChannelPlan(latestRelease.version, force, channel); if ( force || !latestRelease || packageRenameRequiresUpdate || - isNewerPackageVersion(latestRelease.version, VERSION) + isReleaseUpdateCandidate(latestRelease.version, VERSION, channel) ) { return { installSpec, packageName, shouldRun: true, targetVersion: latestRelease?.version }; } } catch { + if (effectiveChannel === "nightly") return nightlyReleaseUnavailablePlan(); return { installSpec: PACKAGE_NAME, packageName: PACKAGE_NAME, shouldRun: true }; } @@ -1578,6 +1663,32 @@ export async function handlePackageCommand(args: string[]): Promise { case "update": { const target = options.updateTarget ?? { type: "all" }; + const includesSelf = updateTargetIncludesSelf(target); + const persistedChannel = settingsManager.getUpdateChannel(); + // Warn and confirm before any update work so declining changes nothing, not even extensions. + if (includesSelf && options.channel === "nightly" && persistedChannel !== "nightly") { + console.log( + chalk.yellow( + `Nightly releases are unreleased ${APP_NAME} builds. They can be broken, and a broken update can leave ${APP_NAME} unusable until you roll back or reinstall.`, + ), + ); + if (!options.force) { + if (!process.stdin.isTTY) { + console.error( + chalk.red( + "Switching to the nightly channel needs confirmation. Re-run with --force to proceed.", + ), + ); + setSelfUpdateAbortedExitCode(); + return true; + } + if (!(await promptYesNo("Switch to the nightly channel and continue with the update?"))) { + console.log(chalk.dim("Update cancelled. Nothing was changed.")); + setSelfUpdateAbortedExitCode(); + return true; + } + } + } if (updateTargetIncludesExtensions(target)) { const updateSource = target.type === "extensions" ? target.source : undefined; await packageManager.update(updateSource); @@ -1587,9 +1698,24 @@ export async function handlePackageCommand(args: string[]): Promise { console.log(chalk.green("Updated packages")); } } - if (updateTargetIncludesSelf(target)) { - const selfUpdatePlan = await getSelfUpdatePlan(options.force, options.rollback); + if (includesSelf) { + const updateChannel = options.channel ?? persistedChannel; + const commitChannel = () => { + if (options.channel && options.channel !== persistedChannel) { + settingsManager.setUpdateChannel(options.channel); + console.log(chalk.dim(`Updates now follow the ${options.channel} channel.`)); + } + }; + const selfUpdatePlan = await getSelfUpdatePlan(options.force, options.rollback, updateChannel); + if (selfUpdatePlan.unavailable) { + // With an all target the extension half already succeeded; the message above + // says Prime Agent itself was not updated, so do not fail the whole run for it. + if (updateTargetIncludesExtensions(target)) setSelfUpdateNoChangeExitCode(); + else setSelfUpdateAbortedExitCode(); + return true; + } if (!selfUpdatePlan.shouldRun) { + commitChannel(); setSelfUpdateNoChangeExitCode(); return true; } @@ -1602,6 +1728,8 @@ export async function handlePackageCommand(args: string[]): Promise { selfUpdatePlan.packageName, ); if (!selfUpdateCommand) { + // The channel switch was already confirmed; keep it even though this install must be updated by hand. + commitChannel(); printSelfUpdateUnavailable( selfUpdateNpmCommand, selfUpdatePlan.installSpec, @@ -1617,9 +1745,11 @@ export async function handlePackageCommand(args: string[]): Promise { if (process.stdin.isTTY) { console.log(chalk.dim("Update cancelled.")); } + // Existing contract: a declined session-loss prompt is a failed update, not the no-change sentinel. process.exitCode = 1; return true; } + commitChannel(); try { await runSelfUpdate(selfUpdateCommand); } catch (error: unknown) { diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index d4bf92f84..895c5e563 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -5,6 +5,8 @@ const STABLE_VERSION_MANIFEST_PATH = "latest.json"; const BETA_VERSION_MANIFEST_PATH = "beta.json"; const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000; +export type UpdateChannel = "stable" | "nightly"; + export interface LatestPiRelease { version: string; packageName?: string; @@ -103,9 +105,52 @@ function normalizeReleaseVersion(version: string): string { return version.trim().replace(/^v/, ""); } -function getReleaseManifestPath(currentVersion: string): string { +/** + * A preferred channel wins; otherwise a build tagged `-beta` stays on nightly and anything else + * follows stable. Nightly builds are what the release bucket publishes as beta. + */ +export function resolveUpdateChannel(currentVersion: string, preferred?: UpdateChannel): UpdateChannel { + if (preferred) return preferred; const prerelease = parsePackageVersion(currentVersion)?.prerelease; - return prerelease?.match(/^beta(?:\.|$)/) ? BETA_VERSION_MANIFEST_PATH : STABLE_VERSION_MANIFEST_PATH; + return prerelease?.match(/^beta(?:\.|$)/) ? "nightly" : "stable"; +} + +/** + * Whether `candidateVersion` should replace `currentVersion` on the effective channel. + * Same-channel updates must be strictly newer. An explicit switch to another channel + * accepts any different version whose base version is not older, so a stable 1.2.3 + * can move onto 1.2.3-beta.5 even though prerelease ordering ranks that lower. + */ +export function isReleaseUpdateCandidate( + candidateVersion: string, + currentVersion: string, + channel?: UpdateChannel, +): boolean { + if (isNewerPackageVersion(candidateVersion, currentVersion)) return true; + if (!channel || channel === resolveUpdateChannel(currentVersion)) return false; + if (normalizeReleaseVersion(candidateVersion) === normalizeReleaseVersion(currentVersion)) return false; + const candidate = parsePackageVersion(candidateVersion); + const current = parsePackageVersion(currentVersion); + if (!candidate || !current) return true; + if (candidate.major !== current.major) return candidate.major > current.major; + if (candidate.minor !== current.minor) return candidate.minor > current.minor; + return candidate.patch >= current.patch; +} + +/** True when installing `candidateVersion` would lower the major.minor.patch base, prerelease tags aside. */ +export function isBaseVersionDowngrade(candidateVersion: string, currentVersion: string): boolean { + const candidate = parsePackageVersion(candidateVersion); + const current = parsePackageVersion(currentVersion); + if (!candidate || !current) return false; + if (candidate.major !== current.major) return candidate.major < current.major; + if (candidate.minor !== current.minor) return candidate.minor < current.minor; + return candidate.patch < current.patch; +} + +function getReleaseManifestPath(currentVersion: string, channel?: UpdateChannel): string { + return resolveUpdateChannel(currentVersion, channel) === "nightly" + ? BETA_VERSION_MANIFEST_PATH + : STABLE_VERSION_MANIFEST_PATH; } function resolveReleaseUrl(baseUrl: string, pathOrUrl: string): string | undefined { @@ -120,12 +165,12 @@ function resolveReleaseUrl(baseUrl: string, pathOrUrl: string): string | undefin export async function getLatestPiRelease( currentVersion: string, - options: { timeoutMs?: number; baseUrl?: string } = {}, + options: { timeoutMs?: number; baseUrl?: string; channel?: UpdateChannel } = {}, ): Promise { if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) return undefined; const baseUrl = options.baseUrl?.replace(/\/+$/, "") ?? getPrimeAgentDownloadBaseUrl(); - const response = await fetch(`${baseUrl}/${getReleaseManifestPath(currentVersion)}`, { + const response = await fetch(`${baseUrl}/${getReleaseManifestPath(currentVersion, options.channel)}`, { headers: { "User-Agent": getPiUserAgent(currentVersion), accept: "application/json", @@ -185,15 +230,18 @@ export async function getLatestPiRelease( export async function getLatestPiVersion( currentVersion: string, - options: { timeoutMs?: number } = {}, + options: { timeoutMs?: number; channel?: UpdateChannel } = {}, ): Promise { return (await getLatestPiRelease(currentVersion, options))?.version; } -export async function checkForNewPiVersion(currentVersion: string): Promise { +export async function checkForNewPiVersion( + currentVersion: string, + channel?: UpdateChannel, +): Promise { try { - const latestVersion = await getLatestPiVersion(currentVersion); - if (latestVersion && isNewerPackageVersion(latestVersion, currentVersion)) { + const latestVersion = await getLatestPiVersion(currentVersion, { channel }); + if (latestVersion && isReleaseUpdateCandidate(latestVersion, currentVersion, channel)) { return latestVersion; } return undefined; diff --git a/packages/coding-agent/test/interactive-mode-startup.test.ts b/packages/coding-agent/test/interactive-mode-startup.test.ts index 4f7186abb..ce39fbf71 100644 --- a/packages/coding-agent/test/interactive-mode-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-startup.test.ts @@ -581,9 +581,7 @@ describe("InteractiveMode startup hints", () => { await Reflect.get(InteractiveMode.prototype, "requestAgentsView").call(mode); - expect(showStatus).toHaveBeenCalledWith( - expect.stringContaining("needs a daemon-hosted session"), - ); + expect(showStatus).toHaveBeenCalledWith(expect.stringContaining("needs a daemon-hosted session")); expect(shutdown).not.toHaveBeenCalled(); }); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 3ddb7055a..aa60db4f9 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -2,8 +2,16 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { APP_NAME, ENV_AGENT_DIR, PACKAGE_NAME, SELF_UPDATE_INTERACTIVE_CHILD_ENV, VERSION } from "../src/config.js"; +import { + APP_NAME, + ENV_AGENT_DIR, + PACKAGE_NAME, + SELF_UPDATE_INTERACTIVE_CHILD_ENV, + SELF_UPDATE_NOT_ATTEMPTED_EXIT_CODE, + VERSION, +} from "../src/config.js"; import { main } from "../src/main.js"; +import { handlePackageCommand } from "../src/package-manager-cli.js"; function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) { @@ -150,6 +158,82 @@ describe("package commands", () => { } }); + it("rejects combining --nightly and --stable", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(main(["update", "--nightly", "--stable"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("--nightly and --stable cannot be combined"); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + + it("refuses to switch to the nightly channel without a TTY or --force and changes nothing", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["update", "--nightly"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("Switching to the nightly channel needs confirmation"); + expect(process.exitCode).toBe(1); + const settingsPath = join(agentDir, "settings.json"); + if (existsSync(settingsPath)) { + expect(JSON.parse(readFileSync(settingsPath, "utf8")).updateChannel).toBeUndefined(); + } + } finally { + errorSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it("keeps a successful extension update when the nightly manifest is missing for an all target", async () => { + process.env.PRIME_AGENT_DOWNLOAD_BASE_URL = "https://downloads.example.test/prime-agent"; + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("", { status: 404 })), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(handlePackageCommand(["update", "--nightly", "--force"])).resolves.toBe(true); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("Updated packages"); + expect(stderr).toContain("Could not resolve a nightly release"); + expect(stderr).toContain("was not updated and the update channel was not changed"); + expect(process.exitCode).toBeUndefined(); + const settingsPath = join(agentDir, "settings.json"); + if (existsSync(settingsPath)) { + expect(JSON.parse(readFileSync(settingsPath, "utf-8")).updateChannel).toBeUndefined(); + } + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it("rejects --nightly for extension-only updates instead of silently ignoring it", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(handlePackageCommand(["update", "--extensions", "--nightly"])).resolves.toBe(true); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("--nightly and --stable only apply to Prime Agent itself"); + expect(process.exitCode).toBe(1); + } finally { + errorSpy.mockRestore(); + } + }); + it("treats -l as an unknown option for package update", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -325,6 +409,104 @@ else { } }); + it("treats a channel that is behind the installed version as nothing to update", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm.cjs"); + const recordPath = join(tempDir, "self-update.json"); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); +else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + process.env.PRIME_AGENT_DOWNLOAD_BASE_URL = "https://downloads.example.test/prime-agent"; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + package: "prime-agent", + tarball: "releases/v0.0.1/prime-agent-0.0.1.tgz", + version: "0.0.1", + }), + ), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(runSelfUpdateInstallChild(["update", "--self"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("is ahead of the stable channel"); + expect(process.exitCode).toBe(SELF_UPDATE_NOT_ATTEMPTED_EXIT_CODE); + expect(existsSync(recordPath)).toBe(false); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + + it("refuses a downgrade even with --nightly --force and leaves the channel unchanged", async () => { + const globalPrefix = join(tempDir, "global-prefix"); + const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const fakeNpmPath = join(tempDir, "fake-npm.cjs"); + const recordPath = join(tempDir, "self-update.json"); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs=require("node:fs"),path=require("node:path"),args=process.argv.slice(2),prefix=args[args.indexOf("--prefix")+1]; +if(args.includes("root")) console.log(path.join(prefix,"lib","node_modules")); +else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); +`, + ); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ npmCommand: [originalExecPath, fakeNpmPath, "--prefix", globalPrefix] }, null, 2), + ); + process.env.PI_PACKAGE_DIR = selfPackageDir; + process.env.PRIME_AGENT_DOWNLOAD_BASE_URL = "https://downloads.example.test/prime-agent"; + Object.defineProperty(process, "execPath", { + value: join(selfPackageDir, "dist", "cli.js"), + configurable: true, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + package: "prime-agent", + tarball: "releases/v0.0.1-beta.1.1.abcdef0/prime-agent-0.0.1-beta.1.1.abcdef0.tgz", + version: "0.0.1-beta.1.1.abcdef0", + }), + ), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(runSelfUpdateInstallChild(["update", "--self", "--nightly", "--force"])).resolves.toBeUndefined(); + + const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stderr).toContain("that is a downgrade"); + expect(existsSync(recordPath)).toBe(false); + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).updateChannel).toBeUndefined(); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); + it("installs the Prime Agent tarball from the update manifest during self-update", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index 50c2a1215..28d6b46fd 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -4,7 +4,10 @@ import { comparePackageVersions, getLatestPiRelease, getLatestPiVersion, + isBaseVersionDowngrade, isNewerPackageVersion, + isReleaseUpdateCandidate, + resolveUpdateChannel, } from "../src/utils/version-check.js"; const defaultPrimeAgentDownloadBaseUrl = "https://pub-728493de92a943e2a9b2d17b4719f318.r2.dev"; @@ -95,3 +98,78 @@ describe("version checks", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe("update channel preference", () => { + it("infers the channel from the running version when none is preferred", () => { + expect(resolveUpdateChannel("1.2.4")).toBe("stable"); + expect(resolveUpdateChannel("1.2.4-beta.123.1.1234567")).toBe("nightly"); + expect(resolveUpdateChannel("1.2.4-beta.123.1.1234567", "stable")).toBe("stable"); + expect(resolveUpdateChannel("1.2.4", "nightly")).toBe("nightly"); + }); + + it("follows a preferred nightly channel from a stable installation", async () => { + delete process.env.PI_SKIP_VERSION_CHECK; + delete process.env.PI_OFFLINE; + const fetchMock = vi.fn(async () => Response.json({ version: "v1.2.5-beta.130.1.abcdef0" })); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestPiVersion("1.2.4", { channel: "nightly" })).resolves.toBe("1.2.5-beta.130.1.abcdef0"); + expect(fetchMock).toHaveBeenCalledWith(`${defaultPrimeAgentDownloadBaseUrl}/beta.json`, expect.any(Object)); + }); + + it("follows a preferred stable channel from a beta installation", async () => { + delete process.env.PI_SKIP_VERSION_CHECK; + delete process.env.PI_OFFLINE; + const fetchMock = vi.fn(async () => Response.json({ version: "v1.2.4" })); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestPiVersion("1.2.4-beta.123.1.1234567", { channel: "stable" })).resolves.toBe("1.2.4"); + expect(fetchMock).toHaveBeenCalledWith(`${defaultPrimeAgentDownloadBaseUrl}/latest.json`, expect.any(Object)); + }); + + it("lets a stable installation move onto the current beta build when nightly is preferred", () => { + expect(isReleaseUpdateCandidate("1.2.3-beta.5.1.abcdef0", "1.2.3", "nightly")).toBe(true); + expect(isReleaseUpdateCandidate("1.2.3-beta.5.1.abcdef0", "1.2.3")).toBe(false); + expect(isReleaseUpdateCandidate("1.2.3-beta.5.1.abcdef0", "1.2.3", "stable")).toBe(false); + }); + + it("never downgrades the base version when switching channels", () => { + expect(isReleaseUpdateCandidate("1.2.2-beta.9.1.abcdef0", "1.2.3", "nightly")).toBe(false); + expect(isReleaseUpdateCandidate("1.2.2", "1.2.3-beta.5.1.abcdef0", "stable")).toBe(false); + expect(isReleaseUpdateCandidate("1.2.3", "1.2.3-beta.5.1.abcdef0", "stable")).toBe(true); + }); + + it("flags only a lower base version as a downgrade", () => { + expect(isBaseVersionDowngrade("1.2.2-beta.9.1.abcdef0", "1.2.3")).toBe(true); + expect(isBaseVersionDowngrade("1.2.2", "1.2.3-beta.5.1.abcdef0")).toBe(true); + expect(isBaseVersionDowngrade("1.2.3-beta.5.1.abcdef0", "1.2.3")).toBe(false); + expect(isBaseVersionDowngrade("1.2.3", "1.2.3-beta.5.1.abcdef0")).toBe(false); + expect(isBaseVersionDowngrade("1.3.0", "1.2.9")).toBe(false); + expect(isBaseVersionDowngrade("not-a-version", "1.2.3")).toBe(false); + }); + + it("keeps same-channel updates strictly newer", () => { + expect(isReleaseUpdateCandidate("1.2.3-beta.5.1.abcdef0", "1.2.3-beta.5.1.abcdef0", "nightly")).toBe(false); + expect(isReleaseUpdateCandidate("1.2.3-beta.4.1.abcdef0", "1.2.3-beta.5.1.abcdef0", "nightly")).toBe(false); + expect(isReleaseUpdateCandidate("1.2.3-beta.6.1.abcdef0", "1.2.3-beta.5.1.abcdef0", "nightly")).toBe(true); + }); + + it("reports the current beta build from a stable installation once nightly is preferred", async () => { + delete process.env.PI_SKIP_VERSION_CHECK; + delete process.env.PI_OFFLINE; + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ version: "v1.2.3-beta.5.1.abcdef0" })), + ); + await expect(checkForNewPiVersion("1.2.3", "nightly")).resolves.toBe("1.2.3-beta.5.1.abcdef0"); + await expect(checkForNewPiVersion("1.2.3")).resolves.toBeUndefined(); + }); + + it("reports a newer beta build when nightly is preferred", async () => { + delete process.env.PI_SKIP_VERSION_CHECK; + delete process.env.PI_OFFLINE; + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ version: "v1.2.5-beta.1.1.abcdef0" })), + ); + await expect(checkForNewPiVersion("1.2.4", "nightly")).resolves.toBe("1.2.5-beta.1.1.abcdef0"); + }); +}); diff --git a/scripts/benchmarks/tests/test_ui.py b/scripts/benchmarks/tests/test_ui.py index 9d9201459..957b7671c 100644 --- a/scripts/benchmarks/tests/test_ui.py +++ b/scripts/benchmarks/tests/test_ui.py @@ -16,6 +16,7 @@ import ui from report import UI_METRICS, render from schema import UI_METRIC_KEYS, Config, Metric, Observation, ProcessMemory, Report, Request, Side from ui import ( + cpu_delta, expand_subagents, expected_roster_inactive, fixture_spec, @@ -437,3 +438,12 @@ class SchemaTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class CpuDeltaTests(unittest.TestCase): + def test_reports_cpu_consumed_since_start(self): + self.assertAlmostEqual(cpu_delta(1.0, 1.5), 0.5) + + def test_clamps_negative_deltas_from_exited_processes_to_zero(self): + self.assertEqual(cpu_delta(5.0, 4.9996), 0.0) + Observation(trial=0, value=cpu_delta(5.0, 4.9996)) diff --git a/scripts/benchmarks/ui.py b/scripts/benchmarks/ui.py index 48b74d8c3..85dd2fbdb 100644 --- a/scripts/benchmarks/ui.py +++ b/scripts/benchmarks/ui.py @@ -343,6 +343,17 @@ def total_cpu(stats: list[ProcessMemory]) -> float: return sum(process.cpu or 0.0 for process in stats) +def cpu_delta(start: float, total: float) -> float: + """CPU seconds consumed since ``start``. + + ``total`` sums the live process tree, so a child that exits between the two + samples takes its CPU time with it and the raw difference can dip slightly + below zero. That is measurement noise, not negative work, and the report + schema rejects negative observations, so clamp at zero. + """ + return max(0.0, total - start) + + def input_ready(terminal: Terminal, marker: str, *, timeout: float = 120) -> float: """Return the first successful editor-echo timestamp after the transcript tail appears.""" terminal.until(lambda display: marker in display.text(), timeout) @@ -637,7 +648,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes bytes_start = terminal.bytes input_ready(terminal, tail_marker("large", 2)) elapsed = time.perf_counter() - terminal.started - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "resume_large", trial, elapsed) # type: ignore[arg-type] record(side, "resume_large_cpu", trial, cpu) # type: ignore[arg-type] note("resume_large", seconds=elapsed, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -660,7 +671,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes terminal.child.send(f"/resume {spec.switch_id}{ENTER}") input_ready(terminal, tail_marker("large", 3)) elapsed = time.perf_counter() - started - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "switch_large", trial, elapsed) # type: ignore[arg-type] record(side, "switch_large_cpu", trial, cpu) # type: ignore[arg-type] note("switch_large", seconds=elapsed, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -672,7 +683,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes bytes_start = terminal.bytes open_agents_view(terminal, clear=False) elapsed = time.perf_counter() - started - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "agents_view", trial, elapsed) # type: ignore[arg-type] record(side, "agents_view_cpu", trial, cpu) # type: ignore[arg-type] note("agents_view", seconds=elapsed, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -682,7 +693,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes cpu_start = cpu_total() bytes_start = terminal.bytes roster_seconds = wait_for_roster(terminal, minimum=expected_roster_inactive()) - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "agents_roster", trial, roster_seconds) # type: ignore[arg-type] record(side, "agents_roster_cpu", trial, cpu) # type: ignore[arg-type] note("agents_roster", seconds=roster_seconds, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -698,7 +709,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes terminal.child.send(RIGHT_ARROW) input_ready(terminal, tail_marker("large", 1)) elapsed = time.perf_counter() - started - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "agents_open", trial, elapsed) # type: ignore[arg-type] record(side, "agents_open_cpu", trial, cpu) # type: ignore[arg-type] note("agents_open", seconds=elapsed, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -715,7 +726,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes terminal.child.send(RIGHT_ARROW) ready_at = input_ready(terminal, tail_marker("large", 1)) elapsed = ready_at - started - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "agents_reopen", trial, elapsed) record(side, "agents_reopen_cpu", trial, cpu) note("agents_reopen", seconds=elapsed, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -733,7 +744,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes terminal.child.send(RIGHT_ARROW) input_ready(terminal, tail_marker("root", 0)) elapsed = time.perf_counter() - started - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "parent_open", trial, elapsed) # type: ignore[arg-type] record(side, "parent_open_cpu", trial, cpu) # type: ignore[arg-type] note("parent_open", seconds=elapsed, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -763,7 +774,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes terminal.child.send(RIGHT_ARROW) input_ready(terminal, tail_marker("sub", SUBAGENT_DEPTH)) elapsed = time.perf_counter() - started - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) record(side, "subagent_open", trial, elapsed) # type: ignore[arg-type] record(side, "subagent_open_cpu", trial, cpu) # type: ignore[arg-type] note("subagent_open", seconds=elapsed, cpu=cpu, pty_bytes=terminal.bytes - bytes_start) @@ -823,7 +834,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes cpu_start = cpu_total() started = client.send(metric, {"type": "heartbeats_list"}) data, finished = client.wait(metric) - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) check_scheduled_jobs(data, expected_jobs) record(side, metric, trial, finished - started) record(side, metric + "_cpu", trial, cpu) @@ -850,7 +861,7 @@ def ui_measure(request: Request, side: Side, trial: int, *, results: Path, homes }, ) summary, finished = client.wait("cold-open") - cpu = cpu_total() - cpu_start + cpu = cpu_delta(cpu_start, cpu_total()) if summary.get("sessionId") != cold_id or summary.get("workerState") != "ready": raise RuntimeError("Cold worker did not return the expected ready session") replies_before_open = len(client.responses) -- 2.51.2