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)