From c2a1bc5cc522a62c10349afc2f72d437f51a0d7e Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 13:31:28 -0700 Subject: [PATCH 1/9] Update when agents finish: deferred self-update for web/VPS and desktop Web/VPS (npx bb-app): the update toast gains "Update when agents finish". The server stages an npm install of the latest bb-app under the data dir while agents keep running, watches for zero busy threads sustained over a 45s quiet period, then exits with a reserved code; the launcher swaps both children to the staged version (seconds of downtime) and falls back to the current version if the staged one fails to start. Capability is gated by a launcher-set env var, so dev servers, standalone bb-server, old launchers, and desktop-owned runtimes never see the protocol. Desktop: the "Desktop update ready" toast gains "Relaunch when agents finish" when the shell owns the local runtime. The main process polls the new /system/agents/activity endpoint and defers electron-updater's quitAndInstall until agents have been idle for the same quiet period. Fixes #477 Co-Authored-By: Claude Fable 5 --- .../cache-owners/cache-owner-registry.test.ts | 3 + .../system-version-cache-owner.ts | 24 + apps/app/src/hooks/queries/system-queries.ts | 35 +- apps/app/src/hooks/useUpdateAvailableToast.ts | 198 +++++++- apps/app/src/lib/api.ts | 13 + apps/desktop/src/desktop-deferred-install.ts | 209 ++++++++ apps/desktop/src/desktop-update-ipc.ts | 4 + apps/desktop/src/main.ts | 72 ++- apps/desktop/src/preload.ts | 22 + .../test/desktop-deferred-install.test.ts | 128 +++++ apps/server/src/routes/system.ts | 20 +- .../src/services/system/agent-activity.ts | 24 + .../server/src/services/system/app-version.ts | 8 +- .../server/src/services/system/self-update.ts | 480 ++++++++++++++++++ apps/server/src/start-server.ts | 21 + apps/server/src/types.ts | 4 + apps/server/test/helpers/test-app.ts | 10 + .../test/public/public-system-version.test.ts | 21 +- .../server/test/system/agent-activity.test.ts | 49 ++ .../test/system/bb-app-managed-config.test.ts | 1 + apps/server/test/system/self-update.test.ts | 342 +++++++++++++ packages/bb-app/src/launcher.ts | 239 ++++++++- packages/bb-app/test/index.test.ts | 180 +++++++ packages/config/package.json | 5 + packages/config/src/env-vars.ts | 7 + packages/config/src/self-update.ts | 68 +++ packages/config/src/server.ts | 9 + packages/desktop-contract/src/info.ts | 27 + packages/server-contract/src/api/system.ts | 46 +- packages/server-contract/src/public-api.ts | 23 + tests/integration/helpers/harness.ts | 10 + 31 files changed, 2250 insertions(+), 52 deletions(-) create mode 100644 apps/app/src/hooks/cache-owners/system-version-cache-owner.ts create mode 100644 apps/desktop/src/desktop-deferred-install.ts create mode 100644 apps/desktop/test/desktop-deferred-install.test.ts create mode 100644 apps/server/src/services/system/agent-activity.ts create mode 100644 apps/server/src/services/system/self-update.ts create mode 100644 apps/server/test/system/agent-activity.test.ts create mode 100644 apps/server/test/system/self-update.test.ts create mode 100644 packages/config/src/self-update.ts diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index 73a8379067..8df1021ddf 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -204,6 +204,9 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadsQueryKey", ], "hooks/cache-owners/system-config-cache-owner.ts": ["systemConfigQueryKey"], + "hooks/cache-owners/system-version-cache-owner.ts": [ + "systemVersionQueryKey", + ], "hooks/cache-owners/terminal-cache-owner.ts": [ "allTerminalsQueryKeyPrefix", "TerminalQueryScope", diff --git a/apps/app/src/hooks/cache-owners/system-version-cache-owner.ts b/apps/app/src/hooks/cache-owners/system-version-cache-owner.ts new file mode 100644 index 0000000000..a4d0fd3048 --- /dev/null +++ b/apps/app/src/hooks/cache-owners/system-version-cache-owner.ts @@ -0,0 +1,24 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { + SystemSelfUpdateState, + SystemVersionResponse, +} from "@bb/server-contract"; +import { systemVersionQueryKey } from "../queries/query-keys"; + +export interface ApplySelfUpdateStateToVersionCacheArgs { + queryClient: QueryClient; + selfUpdate: SystemSelfUpdateState; +} + +/** Fold a schedule/cancel mutation result into the cached version response. */ +export function applySelfUpdateStateToVersionCache( + args: ApplySelfUpdateStateToVersionCacheArgs, +): void { + args.queryClient.setQueryData( + systemVersionQueryKey(), + (previous) => + previous === undefined + ? previous + : { ...previous, selfUpdate: args.selfUpdate }, + ); +} diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 505b64081c..21e51dcd3d 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,13 +1,15 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toRecord } from "@bb/core-ui"; import type { SystemConfigResponse, SystemExecutionOptionsResponse, + SystemSelfUpdateState, SystemVersionResponse, } from "@bb/server-contract"; import type { ProviderCliStatusResponse } from "@bb/host-daemon-contract"; import type { ProviderUsageResponse } from "@bb/host-daemon-contract"; import * as api from "@/lib/api"; +import { applySelfUpdateStateToVersionCache } from "@/hooks/cache-owners/system-version-cache-owner"; import { useSystemRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { hostProviderCliStatusQueryKey, @@ -96,12 +98,43 @@ export function useSystemConfig(options?: QueryOptions) { }); } +/** Poll cadence while a scheduled self-update is pending, so the UI notices + * staging failures and the post-update version bump. */ +const SCHEDULED_SELF_UPDATE_REFETCH_MS = 30_000; + export function useSystemVersion(options?: QueryOptions) { return useQuery({ queryKey: systemVersionQueryKey(), queryFn: ({ signal }) => api.getSystemVersion(signal), enabled: options?.enabled ?? true, ...SERVER_SESSION_QUERY_POLICY, + refetchInterval: (query) => + query.state.data?.selfUpdate.scheduled != null + ? SCHEDULED_SELF_UPDATE_REFETCH_MS + : false, + }); +} + +function useApplySelfUpdateState() { + const queryClient = useQueryClient(); + return (selfUpdate: SystemSelfUpdateState): void => { + applySelfUpdateStateToVersionCache({ queryClient, selfUpdate }); + }; +} + +export function useScheduleSelfUpdate() { + const applySelfUpdateState = useApplySelfUpdateState(); + return useMutation({ + mutationFn: () => api.scheduleSelfUpdate(), + onSuccess: applySelfUpdateState, + }); +} + +export function useCancelSelfUpdate() { + const applySelfUpdateState = useApplySelfUpdateState(); + return useMutation({ + mutationFn: () => api.cancelSelfUpdate(), + onSuccess: applySelfUpdateState, }); } diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index 5c5a16521c..18198fc4f4 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -1,8 +1,13 @@ import { useEffect, useRef, useState } from "react"; import { appToast } from "@/components/ui/app-toast"; import type { BbDesktopApi, BbDesktopInfo } from "@bb/desktop-contract"; +import type { SystemSelfUpdateScheduled } from "@bb/server-contract"; import { getBbDesktopInfo } from "@/lib/bb-desktop"; -import { useSystemVersion } from "./queries/system-queries"; +import { + useCancelSelfUpdate, + useScheduleSelfUpdate, + useSystemVersion, +} from "./queries/system-queries"; const DISMISSED_STORAGE_KEY_PREFIX = "bb:update-toast:dismissed:"; @@ -59,18 +64,54 @@ function appUpdateDescription(latestVersion: string): string { return `${latestVersion} is available. Restart bb-app to update.`; } +function scheduledUpdateToastId(targetVersion: string): string { + return `bb-update-scheduled:${targetVersion}`; +} + +function scheduledUpdateDescription( + scheduled: SystemSelfUpdateScheduled, +): string { + return scheduled.phase === "staging" + ? `Preparing update to ${scheduled.targetVersion}…` + : `bb will update to ${scheduled.targetVersion} once no agents are running.`; +} + function desktopReadyToastDescription(latestVersion: string): string { return `bb desktop ${latestVersion} is ready to install.`; } +function desktopDeferredToastId(latestVersion: string): string { + return `bb-desktop-update-deferred:${latestVersion}`; +} + function relaunchDesktopUpdate(args: DesktopToastActionArgs): void { void args.desktopApi.installUpdate().catch(() => undefined); appToast.dismiss(`bb-desktop-update-ready:${args.latestVersion}`); } +function deferDesktopUpdate(args: DesktopToastActionArgs): void { + void args.desktopApi.installUpdateWhenIdle?.().catch(() => undefined); + appToast.dismiss(`bb-desktop-update-ready:${args.latestVersion}`); +} + +function cancelDeferredDesktopUpdate(args: DesktopToastActionArgs): void { + void args.desktopApi.cancelDeferredInstall?.().catch(() => undefined); + appToast.dismiss(desktopDeferredToastId(args.latestVersion)); +} + export function useUpdateAvailableToast(): void { const { data } = useSystemVersion(); + const scheduleSelfUpdate = useScheduleSelfUpdate(); + const cancelSelfUpdate = useCancelSelfUpdate(); const shownForVersionRef = useRef(null); + /** id of the scheduled-update toast currently on screen (version + phase). */ + const scheduledToastKeyRef = useRef(null); + /** Target of the schedule we last saw, to detect completion and failure. */ + const watchedTargetVersionRef = useRef(null); + const reportedErrorRef = useRef(null); + + const scheduleMutate = scheduleSelfUpdate.mutate; + const cancelMutate = cancelSelfUpdate.mutate; useEffect(() => { if (!data) { @@ -82,6 +123,69 @@ export function useUpdateAvailableToast(): void { if (data.isDevelopment) { return; } + + const { selfUpdate } = data; + const watchedTargetVersion = watchedTargetVersionRef.current; + + if (selfUpdate.scheduled !== null) { + const scheduled = selfUpdate.scheduled; + watchedTargetVersionRef.current = scheduled.targetVersion; + reportedErrorRef.current = null; + // The plain "update available" toast is superseded by the scheduled one. + if (data.latestVersion !== null) { + appToast.dismiss(`bb-update-available:${data.latestVersion}`); + } + // Allow the "update available" toast to come back if this schedule is + // cancelled later. + shownForVersionRef.current = null; + const toastKey = `${scheduled.targetVersion}:${scheduled.phase}`; + if (scheduledToastKeyRef.current === toastKey) { + return; + } + scheduledToastKeyRef.current = toastKey; + const toastId = scheduledUpdateToastId(scheduled.targetVersion); + appToast.message("bb-app update scheduled", { + id: toastId, + description: scheduledUpdateDescription(scheduled), + duration: Infinity, + cancel: { + label: "Cancel update", + onClick: () => { + cancelMutate(); + appToast.dismiss(toastId); + }, + }, + }); + return; + } + + scheduledToastKeyRef.current = null; + + if (watchedTargetVersion !== null) { + appToast.dismiss(scheduledUpdateToastId(watchedTargetVersion)); + if (data.currentVersion === watchedTargetVersion) { + watchedTargetVersionRef.current = null; + appToast.success("bb-app updated", { + description: `bb is now running ${data.currentVersion}.`, + }); + return; + } + if ( + selfUpdate.lastError !== null && + reportedErrorRef.current !== selfUpdate.lastError + ) { + watchedTargetVersionRef.current = null; + reportedErrorRef.current = selfUpdate.lastError; + appToast.error("bb-app update failed", { + description: selfUpdate.lastError, + }); + return; + } + // Cancelled (here or from another client): fall through so the plain + // "update available" toast can show again. + watchedTargetVersionRef.current = null; + } + if (!data.updateAvailable) { return; } @@ -102,10 +206,31 @@ export function useUpdateAvailableToast(): void { return; } shownForVersionRef.current = latestVersion; + const availableToastId = `bb-update-available:${latestVersion}`; appToast.message("bb-app update available", { - id: `bb-update-available:${latestVersion}`, - description: appUpdateDescription(latestVersion), + id: availableToastId, + description: selfUpdate.capable + ? `${latestVersion} is available.` + : appUpdateDescription(latestVersion), duration: Infinity, + ...(selfUpdate.capable + ? { + action: { + label: "Update when agents finish", + onClick: () => { + scheduleMutate(undefined, { + onError: (error) => { + appToast.error("Could not schedule the update", { + description: + error instanceof Error ? error.message : String(error), + }); + }, + }); + appToast.dismiss(availableToastId); + }, + }, + } + : {}), cancel: { label: "Dismiss", onClick: () => { @@ -113,7 +238,7 @@ export function useUpdateAvailableToast(): void { latestVersion, storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, }); - appToast.dismiss(`bb-update-available:${latestVersion}`); + appToast.dismiss(availableToastId); }, }, onDismiss: () => { @@ -123,7 +248,7 @@ export function useUpdateAvailableToast(): void { }); }, }); - }, [data]); + }, [data, scheduleMutate, cancelMutate]); } export function useDesktopUpdateAvailableToast(): void { @@ -174,20 +299,67 @@ export function useDesktopUpdateAvailableToast(): void { if (latestVersion === null) { return; } - if (shownForVersionRef.current === latestVersion) { + + const deferredInstall = desktopInfo.deferredInstall ?? null; + if (deferredInstall !== null) { + const deferredKey = `${latestVersion}:deferred`; + if (shownForVersionRef.current === deferredKey) { + return; + } + shownForVersionRef.current = deferredKey; + appToast.dismiss(`bb-desktop-update-ready:${latestVersion}`); + appToast.message("Desktop update scheduled", { + id: desktopDeferredToastId(latestVersion), + description: `bb desktop will relaunch to ${latestVersion} once no agents are running.`, + duration: Infinity, + cancel: { + label: "Cancel update", + onClick: () => { + cancelDeferredDesktopUpdate({ desktopApi, latestVersion }); + }, + }, + }); return; } - shownForVersionRef.current = latestVersion; + + // Only offer "when agents finish" when the shell owns a local runtime a + // relaunch would interrupt and is new enough to support deferral. + const canDefer = + desktopInfo.canDeferInstall === true && + typeof desktopApi.installUpdateWhenIdle === "function"; + const readyKey = `${latestVersion}:ready`; + if (shownForVersionRef.current === readyKey) { + return; + } + shownForVersionRef.current = readyKey; + appToast.dismiss(desktopDeferredToastId(latestVersion)); appToast.message("Desktop update ready", { id: `bb-desktop-update-ready:${latestVersion}`, description: desktopReadyToastDescription(latestVersion), duration: Infinity, - action: { - label: "Relaunch", - onClick: () => { - relaunchDesktopUpdate({ desktopApi, latestVersion }); - }, - }, + action: canDefer + ? { + label: "Relaunch when agents finish", + onClick: () => { + deferDesktopUpdate({ desktopApi, latestVersion }); + }, + } + : { + label: "Relaunch", + onClick: () => { + relaunchDesktopUpdate({ desktopApi, latestVersion }); + }, + }, + ...(canDefer + ? { + cancel: { + label: "Relaunch now", + onClick: () => { + relaunchDesktopUpdate({ desktopApi, latestVersion }); + }, + }, + } + : {}), }); }, [desktopApi, desktopInfo]); } diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index f1709c82af..c6da5f17d1 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -48,6 +48,7 @@ import type { SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, + SystemSelfUpdateState, SystemVersionResponse, TimelinePaginationCursor, SystemVoiceTranscriptionResponse, @@ -1745,6 +1746,18 @@ export async function getSystemVersion( ); } +export async function scheduleSelfUpdate(): Promise { + return request( + apiClient.system.update.schedule.$post({}), + ); +} + +export async function cancelSelfUpdate(): Promise { + return request( + apiClient.system.update.schedule.$delete({}), + ); +} + export async function getSystemConfig( signal?: AbortSignal, ): Promise { diff --git a/apps/desktop/src/desktop-deferred-install.ts b/apps/desktop/src/desktop-deferred-install.ts new file mode 100644 index 0000000000..18e4ea25e3 --- /dev/null +++ b/apps/desktop/src/desktop-deferred-install.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; +import type { BbDesktopDeferredInstall } from "@bb/desktop-contract"; +import type { DesktopAutoUpdateLogger } from "./desktop-auto-update.js"; + +const DEFERRED_INSTALL_POLL_INTERVAL_MS = 15_000; +/** + * How long the owned runtime must report zero busy agents before the shell + * relaunches. Mirrors the server's own update-when-idle quiet period so a + * queued follow-up or automation handoff doesn't get interrupted. + */ +const DEFERRED_INSTALL_QUIET_PERIOD_MS = 45_000; +const ACTIVITY_FETCH_TIMEOUT_MS = 5_000; + +const agentActivityResponseSchema = z + .object({ + busyThreadCount: z.number().int().min(0), + }) + .passthrough(); + +export interface DeferredInstallProbe { + /** /system/agents/activity URL of the shell-owned runtime. */ + activityUrl: string; +} + +export interface CreateDeferredInstallControllerArgs { + /** + * Where to poll for agent activity, or null when the shell does not own a + * local runtime (attached/remote servers keep their agents across a shell + * relaunch, so deferral is meaningless there). + */ + getProbe: () => DeferredInstallProbe | null; + /** Whether a downloaded update is ready to install right now. */ + isUpdateDownloaded: () => boolean; + /** Performs the actual quit-and-install relaunch. */ + installUpdate: () => Promise | void; + logger: DesktopAutoUpdateLogger; + /** Overrides for tests. Production uses the defaults. */ + fetchImpl?: typeof fetch; + now?: () => number; + pollIntervalMs?: number; + quietPeriodMs?: number; +} + +export interface DeferredInstallController { + /** True when a deferred install can be offered (owned runtime present). */ + canDefer(): boolean; + cancel(): void; + getState(): BbDesktopDeferredInstall | null; + /** Returns true when the deferral was accepted and polling started. */ + request(): boolean; + stop(): void; + subscribe(listener: () => void): () => void; +} + +export function createDeferredInstallController( + args: CreateDeferredInstallControllerArgs, +): DeferredInstallController { + const fetchImpl = args.fetchImpl ?? fetch; + const now = args.now ?? (() => Date.now()); + const pollIntervalMs = args.pollIntervalMs ?? DEFERRED_INSTALL_POLL_INTERVAL_MS; + const quietPeriodMs = args.quietPeriodMs ?? DEFERRED_INSTALL_QUIET_PERIOD_MS; + + let state: BbDesktopDeferredInstall | null = null; + let idleSince: number | null = null; + let pollHandle: ReturnType | null = null; + let installing = false; + const listeners = new Set<() => void>(); + + function notify(): void { + for (const listener of listeners) { + listener(); + } + } + + function clear(): void { + if (pollHandle !== null) { + clearInterval(pollHandle); + pollHandle = null; + } + idleSince = null; + if (state !== null) { + state = null; + notify(); + } + } + + async function fetchBusyThreadCount(activityUrl: string): Promise { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + ACTIVITY_FETCH_TIMEOUT_MS, + ); + try { + const response = await fetchImpl(activityUrl, { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return agentActivityResponseSchema.parse(await response.json()) + .busyThreadCount; + } finally { + clearTimeout(timeoutHandle); + } + } + + async function tick(): Promise { + if (state === null || installing) { + return; + } + if (!args.isUpdateDownloaded()) { + // The pending update went away (e.g. a newer check reset it); the + // deferral no longer has anything to install. + args.logger.info( + "Deferred desktop install cancelled: no downloaded update is pending.", + ); + clear(); + return; + } + const probe = args.getProbe(); + if (probe === null) { + args.logger.info( + "Deferred desktop install cancelled: no owned runtime to watch.", + ); + clear(); + return; + } + + let busyThreadCount: number; + try { + busyThreadCount = await fetchBusyThreadCount(probe.activityUrl); + } catch { + // Unreachable server counts as busy: don't relaunch on missing data. + idleSince = null; + return; + } + + if (busyThreadCount > 0) { + idleSince = null; + return; + } + if (idleSince === null) { + idleSince = now(); + return; + } + if (now() - idleSince < quietPeriodMs) { + return; + } + + installing = true; + args.logger.info( + "No agents running - relaunching to install the desktop update.", + ); + try { + await args.installUpdate(); + } catch (error) { + installing = false; + idleSince = null; + args.logger.error( + `Deferred desktop install failed to relaunch; will retry when idle: ${String(error)}`, + ); + } + } + + return { + canDefer(): boolean { + return args.getProbe() !== null; + }, + cancel(): void { + if (state !== null) { + args.logger.info("Deferred desktop install cancelled by user."); + } + clear(); + }, + getState(): BbDesktopDeferredInstall | null { + return state; + }, + request(): boolean { + if (!args.isUpdateDownloaded() || args.getProbe() === null) { + return false; + } + if (state !== null) { + return true; + } + state = { requestedAt: new Date(now()).toISOString() }; + idleSince = null; + pollHandle = setInterval(() => { + void tick(); + }, pollIntervalMs); + pollHandle.unref(); + void tick(); + notify(); + return true; + }, + stop(): void { + if (pollHandle !== null) { + clearInterval(pollHandle); + pollHandle = null; + } + }, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/apps/desktop/src/desktop-update-ipc.ts b/apps/desktop/src/desktop-update-ipc.ts index 900ce6f24f..78b576afd7 100644 --- a/apps/desktop/src/desktop-update-ipc.ts +++ b/apps/desktop/src/desktop-update-ipc.ts @@ -3,6 +3,10 @@ export const BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL = export const BB_DESKTOP_GET_INFO_CHANNEL = "bb-desktop:get-info"; export const BB_DESKTOP_INFO_CHANGED_CHANNEL = "bb-desktop:info-changed"; export const BB_DESKTOP_INSTALL_UPDATE_CHANNEL = "bb-desktop:install-update"; +export const BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL = + "bb-desktop:install-update-when-idle"; +export const BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL = + "bb-desktop:cancel-deferred-install"; export const BB_DESKTOP_SET_THEME_CHANNEL = "bb-desktop:set-theme"; export const BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL = "bb-desktop:open-external-url"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3079656e82..39160897f4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -84,10 +84,16 @@ import { type DesktopAutoUpdateService, } from "./desktop-auto-update.js"; import { + createDeferredInstallController, + type DeferredInstallController, +} from "./desktop-deferred-install.js"; +import { + BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL, BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, BB_DESKTOP_GET_INFO_CHANNEL, BB_DESKTOP_INFO_CHANGED_CHANNEL, BB_DESKTOP_INSTALL_UPDATE_CHANNEL, + BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, } from "./desktop-update-ipc.js"; @@ -273,6 +279,7 @@ let desktopWindowFactory: DesktopWindowFactory | null = null; let desktopBrowserViewManager: DesktopBrowserViewManager | null = null; let desktopUpdateService: DesktopUpdateService | null = null; let desktopAutoUpdateService: DesktopAutoUpdateService | null = null; +let deferredInstallController: DeferredInstallController | null = null; let currentRuntime: DesktopRuntime | null = null; let currentWindowUrl: string | null = null; let logViewerIpcHandlersInstalled = false; @@ -394,10 +401,18 @@ function mergeDesktopUpdateInfo( } function getCurrentDesktopInfo(): BbDesktopInfo | null { - return mergeDesktopUpdateInfo({ + const merged = mergeDesktopUpdateInfo({ autoInfo: desktopAutoUpdateService?.getInfo() ?? null, feedInfo: desktopUpdateService?.getInfo() ?? null, }); + if (merged === null) { + return null; + } + return { + ...merged, + canDeferInstall: deferredInstallController?.canDefer() ?? false, + deferredInstall: deferredInstallController?.getState() ?? null, + }; } function sendDesktopInfoChanged(): void { @@ -1120,10 +1135,25 @@ function handleBeforeQuit(event: Event): void { }); } +async function quitAndInstallDesktopUpdate(): Promise { + if (desktopAutoUpdateService === null) { + return; + } + if (!desktopAutoUpdateService.getInfo().updateDownloaded) { + desktopAutoUpdateService.installUpdate(); + return; + } + quitting = true; + stoppingForQuit = true; + await finishQuit(); + desktopAutoUpdateService.installUpdate(); +} + async function finishQuit(): Promise { stopPopoutConfigSync(); desktopUpdateService?.stop(); desktopAutoUpdateService?.stop(); + deferredInstallController?.stop(); desktopBrowserViewManager?.destroyAll(); await desktopWindowFactory?.persistOpenWindows(); await stopOwnedRuntime(); @@ -1141,17 +1171,13 @@ function registerDesktopUpdateIpc(): void { return getCurrentDesktopInfo(); }); ipcMain.handle(BB_DESKTOP_INSTALL_UPDATE_CHANNEL, async () => { - if (desktopAutoUpdateService === null) { - return; - } - if (!desktopAutoUpdateService.getInfo().updateDownloaded) { - desktopAutoUpdateService.installUpdate(); - return; - } - quitting = true; - stoppingForQuit = true; - await finishQuit(); - desktopAutoUpdateService.installUpdate(); + await quitAndInstallDesktopUpdate(); + }); + ipcMain.handle(BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL, () => { + deferredInstallController?.request(); + }); + ipcMain.handle(BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL, () => { + deferredInstallController?.cancel(); }); // Renderer pushes the resolved bb theme so the NSWindow appearance — // traffic lights and inactive title-bar chrome — follows bb's theme @@ -1593,12 +1619,34 @@ async function runDesktopApp(): Promise { logger: createDesktopLogger(), updater: createElectronAutoUpdaterAdapter(autoUpdater), }); + deferredInstallController = createDeferredInstallController({ + getProbe: () => { + // Only a shell-owned runtime dies with the shell; relaunching while + // attached to a remote server never interrupts agents. + if (currentRuntime?.ownership !== "spawned") { + return null; + } + return { + activityUrl: new URL( + "/api/v1/system/agents/activity", + currentRuntime.serverUrl, + ).toString(), + }; + }, + isUpdateDownloaded: () => + desktopAutoUpdateService?.getInfo().updateDownloaded ?? false, + installUpdate: () => quitAndInstallDesktopUpdate(), + logger: createDesktopLogger(), + }); desktopUpdateService.subscribe(() => { sendDesktopInfoChanged(); }); desktopAutoUpdateService.subscribe(() => { sendDesktopInfoChanged(); }); + deferredInstallController.subscribe(() => { + sendDesktopInfoChanged(); + }); registerDesktopUpdateIpc(); registerPopoutIpc(); desktopBrowserViewManager = createDesktopBrowserViewManager(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d32fe61f59..e6728ae80f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -26,10 +26,12 @@ import { type BbDesktopTheme, } from "@bb/desktop-contract"; import { + BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL, BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, BB_DESKTOP_GET_INFO_CHANNEL, BB_DESKTOP_INFO_CHANGED_CHANNEL, BB_DESKTOP_INSTALL_UPDATE_CHANNEL, + BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, } from "./desktop-update-ipc.js"; @@ -118,6 +120,14 @@ async function invokeInstallUpdate(): Promise { } } +async function invokeVoidChannel(channel: string): Promise { + try { + await ipcRenderer.invoke(channel); + } catch { + return; + } +} + const browserStateListeners = new Set(); const browserOpenTabListeners = new Set(); const browserScopedOpenTabListeners = @@ -246,6 +256,12 @@ const bbDesktopApi: BbDesktopApi = { get updateDownloaded() { return currentInfo.updateDownloaded; }, + get canDeferInstall() { + return currentInfo.canDeferInstall; + }, + get deferredInstall() { + return currentInfo.deferredInstall; + }, version: currentInfo.version, checkForUpdates() { return invokeDesktopInfo(BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL); @@ -256,6 +272,12 @@ const bbDesktopApi: BbDesktopApi = { installUpdate() { return invokeInstallUpdate(); }, + installUpdateWhenIdle() { + return invokeVoidChannel(BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL); + }, + cancelDeferredInstall() { + return invokeVoidChannel(BB_DESKTOP_CANCEL_DEFERRED_INSTALL_CHANNEL); + }, onChange(listener: BbDesktopInfoChangeHandler): BbDesktopInfoUnsubscribe { listeners.add(listener); return () => { diff --git a/apps/desktop/test/desktop-deferred-install.test.ts b/apps/desktop/test/desktop-deferred-install.test.ts new file mode 100644 index 0000000000..3b124fa5e8 --- /dev/null +++ b/apps/desktop/test/desktop-deferred-install.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createDeferredInstallController, + type CreateDeferredInstallControllerArgs, +} from "../src/desktop-deferred-install.js"; + +const ACTIVITY_URL = "http://127.0.0.1:4990/api/v1/system/agents/activity"; + +const silentLogger = { + error(): void {}, + info(): void {}, + warn(): void {}, +}; + +function activityResponse(busyThreadCount: number): Response { + return new Response(JSON.stringify({ busyThreadCount }), { + headers: { "content-type": "application/json" }, + status: 200, + }); +} + +interface HarnessOverrides { + busyCounts?: () => number | Error; + hasProbe?: boolean; + updateDownloaded?: () => boolean; +} + +function createHarness(overrides: HarnessOverrides = {}) { + const installUpdate = vi.fn(() => Promise.resolve()); + const busyCounts = overrides.busyCounts ?? (() => 0); + const updateDownloaded = overrides.updateDownloaded ?? (() => true); + const fetchImpl = vi.fn(async () => { + const result = busyCounts(); + if (result instanceof Error) { + throw result; + } + return activityResponse(result); + }); + + const args: CreateDeferredInstallControllerArgs = { + getProbe: () => + (overrides.hasProbe ?? true) ? { activityUrl: ACTIVITY_URL } : null, + isUpdateDownloaded: updateDownloaded, + installUpdate, + logger: silentLogger, + fetchImpl: fetchImpl as unknown as typeof fetch, + pollIntervalMs: 5, + quietPeriodMs: 25, + }; + const controller = createDeferredInstallController(args); + return { controller, fetchImpl, installUpdate }; +} + +describe("desktop deferred install controller", () => { + it("refuses to defer without a downloaded update or an owned runtime", () => { + const noUpdate = createHarness({ updateDownloaded: () => false }); + expect(noUpdate.controller.request()).toBe(false); + expect(noUpdate.controller.getState()).toBeNull(); + + const noRuntime = createHarness({ hasProbe: false }); + expect(noRuntime.controller.canDefer()).toBe(false); + expect(noRuntime.controller.request()).toBe(false); + }); + + it("relaunches after agents stay idle for the quiet period", async () => { + const harness = createHarness(); + expect(harness.controller.request()).toBe(true); + expect(harness.controller.getState()).toEqual({ + requestedAt: expect.any(String), + }); + + await vi.waitFor(() => { + expect(harness.installUpdate).toHaveBeenCalledTimes(1); + }); + }); + + it("keeps waiting while agents are busy or the server is unreachable", async () => { + let busy: number | Error = 2; + const harness = createHarness({ busyCounts: () => busy }); + harness.controller.request(); + + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.installUpdate).not.toHaveBeenCalled(); + + busy = new Error("server restarting"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.installUpdate).not.toHaveBeenCalled(); + + busy = 0; + await vi.waitFor(() => { + expect(harness.installUpdate).toHaveBeenCalledTimes(1); + }); + harness.controller.stop(); + }); + + it("cancel clears the deferral and stops polling", async () => { + let busy = 1; + const harness = createHarness({ busyCounts: () => busy }); + const changes: Array> = []; + harness.controller.subscribe(() => { + changes.push(harness.controller.getState()); + }); + + harness.controller.request(); + harness.controller.cancel(); + expect(harness.controller.getState()).toBeNull(); + expect(changes.at(-1)).toBeNull(); + + busy = 0; + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.installUpdate).not.toHaveBeenCalled(); + }); + + it("auto-cancels when the downloaded update goes away", async () => { + let downloaded = true; + const harness = createHarness({ + busyCounts: () => 1, + updateDownloaded: () => downloaded, + }); + harness.controller.request(); + + downloaded = false; + await vi.waitFor(() => { + expect(harness.controller.getState()).toBeNull(); + }); + expect(harness.installUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 932a53e047..1312993892 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -19,6 +19,7 @@ import { resolveVoiceTranscriptionEnabled, transcribeVoiceInput, } from "../services/ai/voice-transcription.js"; +import { countBusyThreads } from "../services/system/agent-activity.js"; import { listSystemProviderInfos, resolveSystemExecutionOptions, @@ -37,7 +38,7 @@ export function registerSystemRoutes( deps: ServerAppDeps, pluginService: PluginService, ): void { - const { get, post, put } = typedRoutes(app, { + const { del, get, post, put } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), }); const routes = publicApiRoutes.system; @@ -157,6 +158,21 @@ export function registerSystemRoutes( }); get(routes.version, async (context) => - context.json(await deps.appVersion.getSystemVersion()), + context.json({ + ...(await deps.appVersion.getSystemVersion()), + selfUpdate: deps.selfUpdate.getState(), + }), + ); + + post(routes.scheduleSelfUpdate, async (context) => + context.json(await deps.selfUpdate.schedule()), + ); + + del(routes.cancelSelfUpdate, async (context) => + context.json(await deps.selfUpdate.cancel()), + ); + + get(routes.agentActivity, (context) => + context.json({ busyThreadCount: countBusyThreads(deps.db) }), ); } diff --git a/apps/server/src/services/system/agent-activity.ts b/apps/server/src/services/system/agent-activity.ts new file mode 100644 index 0000000000..4543f0b851 --- /dev/null +++ b/apps/server/src/services/system/agent-activity.ts @@ -0,0 +1,24 @@ +import { and, count, inArray, isNull } from "drizzle-orm"; +import { threads, type DbQueryConnection } from "@bb/db"; + +/** Thread statuses that count as "an agent is running". */ +export const BUSY_THREAD_STATUSES = ["starting", "active", "stopping"] as const; + +/** + * Number of live threads an update-triggered restart would interrupt. Shared + * by the server's own update-when-idle watcher and the desktop shell's + * deferred-relaunch poller (via /system/agents/activity). + */ +export function countBusyThreads(db: DbQueryConnection): number { + const row = db + .select({ value: count() }) + .from(threads) + .where( + and( + inArray(threads.status, [...BUSY_THREAD_STATUSES]), + isNull(threads.deletedAt), + ), + ) + .get(); + return row?.value ?? 0; +} diff --git a/apps/server/src/services/system/app-version.ts b/apps/server/src/services/system/app-version.ts index 2243f68459..0380adb59a 100644 --- a/apps/server/src/services/system/app-version.ts +++ b/apps/server/src/services/system/app-version.ts @@ -1,6 +1,6 @@ import semver from "semver"; import { z } from "zod"; -import type { SystemVersionResponse } from "@bb/server-contract"; +import type { SystemVersionInfo } from "@bb/server-contract"; import type { ServerLogger, ServerRuntimeConfig } from "../../types.js"; const NPM_LATEST_URL = "https://registry.npmjs.org/bb-app/latest"; @@ -15,7 +15,7 @@ const npmLatestResponseSchema = z .passthrough(); export interface AppVersionService { - getSystemVersion(): Promise; + getSystemVersion(): Promise; } export interface CreateAppVersionServiceArgs { @@ -116,8 +116,8 @@ export function createAppVersionService( } return { - async getSystemVersion(): Promise { - const baseResponse: SystemVersionResponse = { + async getSystemVersion(): Promise { + const baseResponse: SystemVersionInfo = { currentVersion: config.appVersion, latestVersion: null, source: "npm", diff --git a/apps/server/src/services/system/self-update.ts b/apps/server/src/services/system/self-update.ts new file mode 100644 index 0000000000..73580f78d7 --- /dev/null +++ b/apps/server/src/services/system/self-update.ts @@ -0,0 +1,480 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import semver from "semver"; +import { z } from "zod"; +import { + BB_SELF_UPDATE_EXIT_CODE, + formatSelfUpdateSentinelPath, + formatSelfUpdateStagingDir, + formatSelfUpdateStagingRoot, + formatStagedPackageRoot, + selfUpdateSentinelSchema, + type SelfUpdateSentinel, +} from "@bb/config/self-update"; +import type { DbQueryConnection } from "@bb/db"; +import type { + SystemSelfUpdateScheduled, + SystemSelfUpdateState, +} from "@bb/server-contract"; +import { ApiError } from "../../errors.js"; +import type { ServerLogger, ServerRuntimeConfig } from "../../types.js"; +import { countBusyThreads } from "./agent-activity.js"; +import type { AppVersionService } from "./app-version.js"; + +const DEFAULT_POLL_INTERVAL_MS = 15_000; +/** + * How long the server must see zero busy agents before it exits for the swap. + * Covers the gaps where a thread is momentarily idle between a finished turn + * and a queued follow-up or automation-triggered turn starting. + */ +const DEFAULT_QUIET_PERIOD_MS = 45_000; +const STAGING_INSTALL_TIMEOUT_MS = 10 * 60 * 1000; +const INSTALL_LOG_TAIL_CHARS = 2_000; + +const stagedPackageJsonSchema = z + .object({ version: z.string().min(1) }) + .passthrough(); + +export interface StagingInstallArgs { + packageSpec: string; + prefixDir: string; +} + +export type RunStagingInstallFn = (args: StagingInstallArgs) => Promise; + +export interface SelfUpdateService { + getState(): SystemSelfUpdateState; + schedule(): Promise; + cancel(): Promise; + /** Adopt a sentinel left by a previous run and clean stale staged installs. */ + resume(): Promise; + stop(): void; +} + +export interface CreateSelfUpdateServiceArgs { + appVersion: AppVersionService; + config: Pick< + ServerRuntimeConfig, + "appVersion" | "dataDir" | "isDevelopment" | "selfUpdateProtocol" + >; + db: DbQueryConnection; + logger: ServerLogger; + /** Runs the graceful server shutdown before the process exits for the swap. */ + prepareShutdown: () => Promise; + /** Overrides for tests. Production uses the defaults. */ + exitProcess?: (code: number) => void; + now?: () => number; + pollIntervalMs?: number; + quietPeriodMs?: number; + runStagingInstall?: RunStagingInstallFn; +} + +export function resolveNpmInvocation(env: NodeJS.ProcessEnv): { + command: string; + argsPrefix: string[]; +} { + // Only honor npm_execpath when it is actually npm: when bb was launched + // from a pnpm/yarn-managed process the variable points at that tool, which + // rejects the npm flags we pass. + const npmExecPath = env.npm_execpath; + if ( + npmExecPath !== undefined && + /\.(c|m)?js$/.test(npmExecPath) && + /(^|\/)npm(-cli)?\.(c|m)?js$/.test(npmExecPath) + ) { + return { command: process.execPath, argsPrefix: [npmExecPath] }; + } + return { command: "npm", argsPrefix: [] }; +} + +async function runNpmStagingInstall(args: StagingInstallArgs): Promise { + const { command, argsPrefix } = resolveNpmInvocation(process.env); + const installArgs = [ + ...argsPrefix, + "install", + args.packageSpec, + "--prefix", + args.prefixDir, + "--no-audit", + "--no-fund", + "--loglevel", + "error", + ]; + + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, installArgs, { + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let outputTail = ""; + const collect = (chunk: Buffer): void => { + outputTail = (outputTail + chunk.toString("utf8")).slice( + -INSTALL_LOG_TAIL_CHARS, + ); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + + const timeoutHandle = setTimeout(() => { + child.kill("SIGKILL"); + }, STAGING_INSTALL_TIMEOUT_MS); + timeoutHandle.unref(); + + child.once("error", (error) => { + clearTimeout(timeoutHandle); + rejectPromise( + new Error(`Failed to run ${command} for staging install: ${error.message}`), + ); + }); + child.once("close", (code, signal) => { + clearTimeout(timeoutHandle); + if (code === 0) { + resolvePromise(); + return; + } + const exitDescription = + signal !== null ? `signal ${signal}` : `exit code ${code}`; + rejectPromise( + new Error( + `npm install ${args.packageSpec} failed (${exitDescription})${ + outputTail.trim().length > 0 ? `: ${outputTail.trim()}` : "" + }`, + ), + ); + }); + }); +} + +async function readStagedPackageVersion( + stagedPackageRoot: string, +): Promise { + const rawContents = await readFile( + join(stagedPackageRoot, "package.json"), + "utf8", + ); + return stagedPackageJsonSchema.parse(JSON.parse(rawContents)).version; +} + +async function writeSentinelFile( + dataDir: string, + sentinel: SelfUpdateSentinel, +): Promise { + const sentinelPath = formatSelfUpdateSentinelPath(dataDir); + const tempPath = join( + dataDir, + `.self-update.json.${process.pid}.${randomUUID()}.tmp`, + ); + try { + await writeFile(tempPath, `${JSON.stringify(sentinel, null, 2)}\n`, "utf8"); + await rename(tempPath, sentinelPath); + } catch (error) { + await rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} + +async function readSentinelFile( + dataDir: string, +): Promise { + try { + const rawContents = await readFile( + formatSelfUpdateSentinelPath(dataDir), + "utf8", + ); + return selfUpdateSentinelSchema.parse(JSON.parse(rawContents)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return null; + } + throw error; + } +} + +export function createSelfUpdateService( + args: CreateSelfUpdateServiceArgs, +): SelfUpdateService { + const { appVersion, config, db, logger } = args; + const capable = config.selfUpdateProtocol && !config.isDevelopment; + const exitProcess = args.exitProcess ?? ((code: number) => process.exit(code)); + const now = args.now ?? (() => Date.now()); + const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const quietPeriodMs = args.quietPeriodMs ?? DEFAULT_QUIET_PERIOD_MS; + const runStagingInstall = args.runStagingInstall ?? runNpmStagingInstall; + + let scheduled: SystemSelfUpdateScheduled | null = null; + let lastError: string | null = null; + let idleSince: number | null = null; + let watcherHandle: ReturnType | null = null; + let exiting = false; + // Bumped on cancel/re-schedule so an in-flight staging install of a stale + // schedule can't adopt state when it eventually finishes. + let scheduleGeneration = 0; + + function getState(): SystemSelfUpdateState { + return { capable, scheduled, lastError }; + } + + function hasBusyThreads(): boolean { + return countBusyThreads(db) > 0; + } + + function stopWatcher(): void { + if (watcherHandle !== null) { + clearInterval(watcherHandle); + watcherHandle = null; + } + idleSince = null; + } + + function startWatcher(): void { + if (watcherHandle !== null) { + return; + } + idleSince = null; + watcherHandle = setInterval(() => { + try { + watcherTick(); + } catch (error) { + logger.warn({ err: error }, "Self-update idle check failed"); + idleSince = null; + } + }, pollIntervalMs); + watcherHandle.unref(); + // Check immediately so an already-idle server doesn't wait a full poll + // interval before the quiet period starts counting. + watcherTick(); + } + + function watcherTick(): void { + if (scheduled === null || scheduled.phase !== "waiting" || exiting) { + stopWatcher(); + return; + } + if (hasBusyThreads()) { + idleSince = null; + return; + } + if (idleSince === null) { + idleSince = now(); + return; + } + if (now() - idleSince < quietPeriodMs) { + return; + } + triggerExitForSwap(); + } + + function triggerExitForSwap(): void { + if (exiting || scheduled === null) { + return; + } + exiting = true; + stopWatcher(); + logger.info( + { targetVersion: scheduled.targetVersion }, + "No agents running - exiting so the launcher can apply the staged update", + ); + void args + .prepareShutdown() + .catch((error) => { + logger.warn({ err: error }, "Graceful shutdown before update failed"); + }) + .finally(() => { + exitProcess(BB_SELF_UPDATE_EXIT_CODE); + }); + } + + async function removeStagedVersion(targetVersion: string): Promise { + await rm(formatSelfUpdateStagingDir(config.dataDir, targetVersion), { + force: true, + recursive: true, + }).catch(() => undefined); + } + + /** + * Remove staged installs for versions that are neither the running version + * (the server may have been started from that staged root by the launcher) + * nor a pending schedule target. + */ + async function cleanupStaleStaging(): Promise { + const stagingRoot = formatSelfUpdateStagingRoot(config.dataDir); + let entries: string[]; + try { + entries = await readdir(stagingRoot); + } catch { + return; + } + for (const entry of entries) { + if (entry === config.appVersion || entry === scheduled?.targetVersion) { + continue; + } + await rm(join(stagingRoot, entry), { + force: true, + recursive: true, + }).catch(() => undefined); + } + } + + async function stageAndArm( + targetVersion: string, + requestedAt: string, + generation: number, + ): Promise { + const stagingDir = formatSelfUpdateStagingDir(config.dataDir, targetVersion); + const stagedPackageRoot = formatStagedPackageRoot( + config.dataDir, + targetVersion, + ); + try { + await mkdir(stagingDir, { recursive: true }); + await runStagingInstall({ + packageSpec: `bb-app@${targetVersion}`, + prefixDir: stagingDir, + }); + const stagedVersion = await readStagedPackageVersion(stagedPackageRoot); + if (stagedVersion !== targetVersion) { + throw new Error( + `Staged bb-app version ${stagedVersion} does not match requested ${targetVersion}`, + ); + } + if (generation !== scheduleGeneration) { + // Cancelled (or re-scheduled) while installing; the staging dir was + // already queued for removal by cancel(). + return; + } + await writeSentinelFile(config.dataDir, { + targetVersion, + stagedPackageRoot, + requestedAt, + }); + scheduled = { targetVersion, requestedAt, phase: "waiting" }; + logger.info( + { targetVersion }, + "Self-update staged - waiting for agents to finish", + ); + startWatcher(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn( + { err: error, targetVersion }, + "Failed to stage self-update", + ); + if (generation === scheduleGeneration) { + // Stale generations must not touch the dir: a newer schedule for the + // same version may be staging into it right now. cancel()/boot + // cleanup remove their leftovers. + await removeStagedVersion(targetVersion); + scheduled = null; + lastError = message; + } + } + } + + async function schedule(): Promise { + if (!capable) { + throw new ApiError( + 409, + "self_update_unavailable", + "This server is not managed by a bb-app launcher that supports scheduled updates. Restart bb-app manually to update.", + ); + } + const versionInfo = await appVersion.getSystemVersion(); + if (!versionInfo.updateAvailable || versionInfo.latestVersion === null) { + throw new ApiError(409, "no_update_available", "bb is already up to date."); + } + const targetVersion = versionInfo.latestVersion; + if (scheduled !== null && scheduled.targetVersion === targetVersion) { + return getState(); + } + if (scheduled !== null) { + await clearSchedule(); + } + + lastError = null; + const requestedAt = new Date(now()).toISOString(); + scheduled = { targetVersion, requestedAt, phase: "staging" }; + const generation = scheduleGeneration; + void stageAndArm(targetVersion, requestedAt, generation); + return getState(); + } + + async function clearSchedule(): Promise { + const previous = scheduled; + scheduled = null; + scheduleGeneration += 1; + stopWatcher(); + await rm(formatSelfUpdateSentinelPath(config.dataDir), { + force: true, + }).catch(() => undefined); + if (previous !== null) { + await removeStagedVersion(previous.targetVersion); + } + } + + async function cancel(): Promise { + if (scheduled !== null) { + logger.info( + { targetVersion: scheduled.targetVersion }, + "Cancelled scheduled self-update", + ); + await clearSchedule(); + } + return getState(); + } + + async function resume(): Promise { + if (!capable) { + return; + } + let sentinel: SelfUpdateSentinel | null = null; + try { + sentinel = await readSentinelFile(config.dataDir); + } catch (error) { + logger.warn({ err: error }, "Ignoring unreadable self-update sentinel"); + await rm(formatSelfUpdateSentinelPath(config.dataDir), { + force: true, + }).catch(() => undefined); + } + + if (sentinel !== null) { + const targetIsNewer = + semver.valid(sentinel.targetVersion) !== null && + semver.valid(config.appVersion) !== null && + semver.gt(sentinel.targetVersion, config.appVersion); + let stagedVersionMatches = false; + if (targetIsNewer) { + stagedVersionMatches = await readStagedPackageVersion( + sentinel.stagedPackageRoot, + ) + .then((version) => version === sentinel.targetVersion) + .catch(() => false); + } + if (targetIsNewer && stagedVersionMatches) { + scheduled = { + targetVersion: sentinel.targetVersion, + requestedAt: sentinel.requestedAt, + phase: "waiting", + }; + logger.info( + { targetVersion: sentinel.targetVersion }, + "Resuming scheduled self-update from previous run", + ); + startWatcher(); + } else { + await rm(formatSelfUpdateSentinelPath(config.dataDir), { + force: true, + }).catch(() => undefined); + } + } + + await cleanupStaleStaging(); + } + + function stop(): void { + stopWatcher(); + } + + return { getState, schedule, cancel, resume, stop }; +} diff --git a/apps/server/src/start-server.ts b/apps/server/src/start-server.ts index cdfa200e68..fe4cd82fef 100644 --- a/apps/server/src/start-server.ts +++ b/apps/server/src/start-server.ts @@ -13,6 +13,7 @@ import { createMachineAuthService } from "./services/machine-auth.js"; import { resolveBuiltinSkillsRootPath } from "./services/skills/builtin-skills-copy.js"; import { createAppVersionService } from "./services/system/app-version.js"; import { createBbAppManagedConfigReloader } from "./services/system/bb-app-managed-config.js"; +import { createSelfUpdateService } from "./services/system/self-update.js"; import { startEventLoopStallMonitor } from "./services/system/event-loop-stall-monitor.js"; import { runPeriodicSweeps, @@ -175,6 +176,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { inferenceModel: serverConfig.BB_INFERENCE, isDevelopment: !isProduction, openAiApiKey: serverConfig.OPENAI_API_KEY, + selfUpdateProtocol: serverConfig.BB_SELF_UPDATE_PROTOCOL, serverPort: serverConfig.BB_SERVER_PORT, threadStorageRootPath, transcriptionModel: serverConfig.BB_TRANSCRIPTION, @@ -235,6 +237,17 @@ export async function runServer(serverConfig: ServerConfig): Promise { logger, }); + // Late-bound so the self-update exit path can run the same graceful + // shutdown as SIGTERM; runShutdown is defined after the listener exists. + const selfUpdateShutdown = { run: (): Promise => Promise.resolve() }; + const selfUpdate = createSelfUpdateService({ + appVersion, + config: runtimeConfig, + db, + logger, + prepareShutdown: () => selfUpdateShutdown.run(), + }); + const { app, closeWebSockets, injectWebSocket, pluginService } = createApp( { appVersion, @@ -246,6 +259,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { logger, machineAuth, pendingInteractions, + selfUpdate, telemetry, terminalSessions, watchInterests, @@ -313,6 +327,7 @@ export async function runServer(serverConfig: ServerConfig): Promise { shutdownPromise = (async () => { eventLoopStallMonitor.stop(); clearInterval(sweepInterval); + selfUpdate.stop(); await pluginService.stop().catch((error: unknown) => { logger.warn({ err: error }, "Plugin shutdown failed"); }); @@ -337,4 +352,10 @@ export async function runServer(serverConfig: ServerConfig): Promise { process.once("SIGTERM", () => { void runShutdown().finally(() => process.exit(0)); }); + + selfUpdateShutdown.run = runShutdown; + // Adopt a schedule that survived a restart and prune stale staged installs. + void selfUpdate.resume().catch((error) => { + logger.warn({ err: error }, "Self-update resume failed"); + }); } diff --git a/apps/server/src/types.ts b/apps/server/src/types.ts index 7fd3a1ae12..18e217484d 100644 --- a/apps/server/src/types.ts +++ b/apps/server/src/types.ts @@ -10,6 +10,7 @@ import type { PendingInteractionLifecycle } from "./services/interactions/pendin import type { MachineAuthService } from "./services/machine-auth.js"; import type { AppVersionService } from "./services/system/app-version.js"; import type { BbAppManagedConfigReloader } from "./services/system/bb-app-managed-config.js"; +import type { SelfUpdateService } from "./services/system/self-update.js"; import type { TelemetryService } from "./services/system/telemetry.js"; import type { TerminalSessionLifecycle } from "./services/terminals/terminal-session-lifecycle.js"; import type { LifecycleDedupers } from "./lifecycle-dedupers.js"; @@ -31,6 +32,8 @@ export interface ServerRuntimeConfig { inferenceModel: string; isDevelopment: boolean; openAiApiKey: string; + /** True when the parent bb-app launcher supports the self-update swap. */ + selfUpdateProtocol: boolean; serverPort: number; threadStorageRootPath: string; transcriptionModel: string; @@ -54,6 +57,7 @@ export interface AppDeps { export interface ServerAppDeps extends AppDeps { appVersion: AppVersionService; bbAppManagedConfig: BbAppManagedConfigReloader; + selfUpdate: SelfUpdateService; } export type LifecycleDeps = Pick< diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 57d8f4ee6e..95e8b854ec 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -14,6 +14,7 @@ import { type AppVersionService, } from "../../src/services/system/app-version.js"; import { createBbAppManagedConfigReloader } from "../../src/services/system/bb-app-managed-config.js"; +import { createSelfUpdateService } from "../../src/services/system/self-update.js"; import { createNoopTelemetryService } from "../../src/services/system/telemetry.js"; import { TerminalSessionLifecycle } from "../../src/services/terminals/terminal-session-lifecycle.js"; import { resolveThreadStorageRootPath } from "../../src/services/threads/thread-storage.js"; @@ -129,6 +130,7 @@ export async function createTestAppHarness( inferenceModel: "test/mock-model", isDevelopment: true, openAiApiKey: "test-openai-key", + selfUpdateProtocol: false, serverPort: 3334, threadStorageRootPath: resolveThreadStorageRootPath({ dataDir, @@ -169,6 +171,13 @@ export async function createTestAppHarness( config, logger: testLogger, }); + const selfUpdate = createSelfUpdateService({ + appVersion, + config, + db, + logger: testLogger, + prepareShutdown: () => Promise.resolve(), + }); const deps: ServerAppDeps = { appVersion, bbAppManagedConfig, @@ -179,6 +188,7 @@ export async function createTestAppHarness( logger: testLogger, machineAuth: testMachineAuth, pendingInteractions, + selfUpdate, telemetry, terminalSessions, watchInterests, diff --git a/apps/server/test/public/public-system-version.test.ts b/apps/server/test/public/public-system-version.test.ts index e06109b9a0..72acf1b389 100644 --- a/apps/server/test/public/public-system-version.test.ts +++ b/apps/server/test/public/public-system-version.test.ts @@ -1,19 +1,22 @@ import { describe, expect, it } from "vitest"; -import type { SystemVersionResponse } from "@bb/server-contract"; +import type { + SystemVersionInfo, + SystemVersionResponse, +} from "@bb/server-contract"; import { readJson } from "../helpers/json.js"; import { withTestHarness } from "../helpers/test-app.js"; -function createStubAppVersionService(response: SystemVersionResponse) { +function createStubAppVersionService(response: SystemVersionInfo) { return { - async getSystemVersion(): Promise { + async getSystemVersion(): Promise { return response; }, }; } describe("GET /api/v1/system/version", () => { - it("returns the response from the app-version service", async () => { - const expected: SystemVersionResponse = { + it("returns the app-version info plus the self-update state", async () => { + const versionInfo: SystemVersionInfo = { currentVersion: "0.0.5", latestVersion: "0.0.6", source: "npm", @@ -23,12 +26,15 @@ describe("GET /api/v1/system/version", () => { }; await withTestHarness({ appVersion: "0.0.5", - appVersionService: createStubAppVersionService(expected), + appVersionService: createStubAppVersionService(versionInfo), isDevelopment: false, }, async (harness) => { const response = await harness.app.request("/api/v1/system/version"); expect(response.status).toBe(200); - await expect(readJson(response)).resolves.toEqual(expected); + await expect(readJson(response)).resolves.toEqual({ + ...versionInfo, + selfUpdate: { capable: false, scheduled: null, lastError: null }, + }); }); }); @@ -51,6 +57,7 @@ describe("GET /api/v1/system/version", () => { expect(body.isDevelopment).toBe(true); expect(body.updateAvailable).toBe(false); expect(body.latestVersion).toBeNull(); + expect(body.selfUpdate.capable).toBe(false); }); }); }); diff --git a/apps/server/test/system/agent-activity.test.ts b/apps/server/test/system/agent-activity.test.ts new file mode 100644 index 0000000000..2ca223a6ba --- /dev/null +++ b/apps/server/test/system/agent-activity.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { projects, threads } from "@bb/db"; +import type { ThreadStatus } from "@bb/domain"; +import { readJson } from "../helpers/json.js"; +import { withTestHarness } from "../helpers/test-app.js"; + +describe("GET /api/v1/system/agents/activity", () => { + it("counts only live busy threads", async () => { + await withTestHarness({}, async (harness) => { + const now = Date.now(); + harness.db + .insert(projects) + .values({ id: "proj_1", name: "Test", createdAt: now, updatedAt: now }) + .run(); + const thread = ( + id: string, + status: ThreadStatus, + deletedAt: number | null, + ) => + harness.db + .insert(threads) + .values({ + id, + projectId: "proj_1", + providerId: "test-provider", + status, + deletedAt, + latestAttentionAt: now, + createdAt: now, + updatedAt: now, + }) + .run(); + thread("thr_active", "active", null); + thread("thr_starting", "starting", null); + thread("thr_stopping", "stopping", null); + thread("thr_idle", "idle", null); + thread("thr_error", "error", null); + thread("thr_deleted", "active", now); + + const response = await harness.app.request( + "/api/v1/system/agents/activity", + ); + expect(response.status).toBe(200); + await expect(readJson(response)).resolves.toEqual({ + busyThreadCount: 3, + }); + }); + }); +}); diff --git a/apps/server/test/system/bb-app-managed-config.test.ts b/apps/server/test/system/bb-app-managed-config.test.ts index c351056c42..b87af78487 100644 --- a/apps/server/test/system/bb-app-managed-config.test.ts +++ b/apps/server/test/system/bb-app-managed-config.test.ts @@ -73,6 +73,7 @@ function createRuntimeConfig(): ServerRuntimeConfig { inferenceModel: "openai/gpt-4o-mini", isDevelopment: false, openAiApiKey: "ambient-openai-key", + selfUpdateProtocol: false, serverPort: 38886, threadStorageRootPath: "/tmp/bb-test/thread-storage", transcriptionModel: "openai/gpt-4o-transcribe", diff --git a/apps/server/test/system/self-update.test.ts b/apps/server/test/system/self-update.test.ts new file mode 100644 index 0000000000..93e510361e --- /dev/null +++ b/apps/server/test/system/self-update.test.ts @@ -0,0 +1,342 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BB_SELF_UPDATE_EXIT_CODE, + formatSelfUpdateSentinelPath, + formatSelfUpdateStagingDir, + formatStagedPackageRoot, +} from "@bb/config/self-update"; +import { projects, threads } from "@bb/db"; +import type { ThreadStatus } from "@bb/domain"; +import type { SystemVersionInfo } from "@bb/server-contract"; +import { initDb } from "../../src/db.js"; +import { ApiError } from "../../src/errors.js"; +import { + createSelfUpdateService, + type CreateSelfUpdateServiceArgs, + type StagingInstallArgs, +} from "../../src/services/system/self-update.js"; +import { testLogger } from "../helpers/test-app.js"; + +const CURRENT_VERSION = "0.0.10"; +const TARGET_VERSION = "0.0.11"; + +interface HarnessOverrides { + latestVersion?: string | null; + selfUpdateProtocol?: boolean; + runStagingInstall?: (args: StagingInstallArgs) => Promise; +} + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } +}); + +function createStubAppVersionService(latestVersion: string | null) { + return { + async getSystemVersion(): Promise { + return { + currentVersion: CURRENT_VERSION, + latestVersion, + source: "npm", + updateAvailable: + latestVersion !== null && latestVersion !== CURRENT_VERSION, + isDevelopment: false, + upgradeCommand: "npx bb-app@latest", + }; + }, + }; +} + +async function writeStagedPackage( + dataDir: string, + version: string, +): Promise { + const stagedPackageRoot = formatStagedPackageRoot(dataDir, version); + await mkdir(stagedPackageRoot, { recursive: true }); + await writeFile( + join(stagedPackageRoot, "package.json"), + JSON.stringify({ name: "bb-app", version }), + "utf8", + ); +} + +async function createHarness(overrides: HarnessOverrides = {}) { + const dataDir = await mkdtemp(join(tmpdir(), "bb-self-update-test-")); + cleanups.push(() => rm(dataDir, { force: true, recursive: true })); + const db = initDb(":memory:"); + const now = Date.now(); + db.insert(projects) + .values({ id: "proj_1", name: "Test", createdAt: now, updatedAt: now }) + .run(); + + const exitProcess = vi.fn(); + const prepareShutdown = vi.fn(() => Promise.resolve()); + const runStagingInstall = + overrides.runStagingInstall ?? + (async (args: StagingInstallArgs) => { + const version = args.packageSpec.split("@")[1]; + await writeStagedPackage(dataDir, version); + }); + + const serviceArgs: CreateSelfUpdateServiceArgs = { + appVersion: createStubAppVersionService( + overrides.latestVersion === undefined + ? TARGET_VERSION + : overrides.latestVersion, + ), + config: { + appVersion: CURRENT_VERSION, + dataDir, + isDevelopment: false, + selfUpdateProtocol: overrides.selfUpdateProtocol ?? true, + }, + db, + logger: testLogger, + prepareShutdown, + exitProcess, + pollIntervalMs: 5, + quietPeriodMs: 25, + runStagingInstall, + }; + const service = createSelfUpdateService(serviceArgs); + cleanups.push(async () => service.stop()); + + function insertThread(id: string, status: ThreadStatus): void { + db.insert(threads) + .values({ + id, + projectId: "proj_1", + providerId: "test-provider", + status, + latestAttentionAt: now, + createdAt: now, + updatedAt: now, + }) + .run(); + } + + function setThreadStatus(id: string, status: ThreadStatus): void { + db.update(threads).set({ status }).where(eq(threads.id, id)).run(); + } + + return { + dataDir, + db, + exitProcess, + insertThread, + prepareShutdown, + service, + serviceArgs, + setThreadStatus, + }; +} + +async function waitForWaitingPhase( + service: Awaited>["service"], +): Promise { + await vi.waitFor(() => { + expect(service.getState().scheduled?.phase).toBe("waiting"); + }); +} + +describe("self-update service", () => { + it("stages the update, writes the sentinel, and exits once agents stay idle", async () => { + const harness = await createHarness(); + const state = await harness.service.schedule(); + expect(state.scheduled).toEqual({ + targetVersion: TARGET_VERSION, + requestedAt: expect.any(String), + phase: "staging", + }); + + await waitForWaitingPhase(harness.service); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).resolves.toBeDefined(); + + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + expect(harness.prepareShutdown).toHaveBeenCalledTimes(1); + }); + + it("waits for busy threads to finish before exiting", async () => { + const harness = await createHarness(); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule(); + await waitForWaitingPhase(harness.service); + + // Comfortably past the quiet period while the thread is still active. + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + + harness.setThreadStatus("thr_busy", "idle"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + + it("does not exit while any agent is busy, even across handoffs", async () => { + const harness = await createHarness(); + harness.insertThread("thr_gate", "active"); + await harness.service.schedule(); + await waitForWaitingPhase(harness.service); + + // One agent finishes while another starts in the same instant: the + // watcher must never observe an idle server. + harness.setThreadStatus("thr_gate", "idle"); + harness.insertThread("thr_late", "starting"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + + harness.setThreadStatus("thr_late", "idle"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + + it("cancel removes the sentinel and staged install and stops the watcher", async () => { + const harness = await createHarness(); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule(); + await waitForWaitingPhase(harness.service); + + const state = await harness.service.cancel(); + expect(state.scheduled).toBeNull(); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + stat(formatSelfUpdateStagingDir(harness.dataDir, TARGET_VERSION)), + ).rejects.toMatchObject({ code: "ENOENT" }); + + harness.setThreadStatus("thr_busy", "idle"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + }); + + it("records a staging failure without leaving a sentinel behind", async () => { + const harness = await createHarness({ + runStagingInstall: async () => { + throw new Error("npm exploded"); + }, + }); + await harness.service.schedule(); + await vi.waitFor(() => { + expect(harness.service.getState().scheduled).toBeNull(); + }); + expect(harness.service.getState().lastError).toContain("npm exploded"); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects scheduling when no update is available or when not capable", async () => { + const upToDate = await createHarness({ latestVersion: CURRENT_VERSION }); + await expect(upToDate.service.schedule()).rejects.toMatchObject({ + body: { code: "no_update_available" }, + }); + + const incapable = await createHarness({ selfUpdateProtocol: false }); + await expect(incapable.service.schedule()).rejects.toBeInstanceOf(ApiError); + expect(incapable.service.getState().capable).toBe(false); + }); + + it("resumes a pending schedule from the sentinel on boot", async () => { + const harness = await createHarness(); + harness.insertThread("thr_busy", "active"); + await writeStagedPackage(harness.dataDir, TARGET_VERSION); + await writeFile( + formatSelfUpdateSentinelPath(harness.dataDir), + JSON.stringify({ + targetVersion: TARGET_VERSION, + stagedPackageRoot: formatStagedPackageRoot( + harness.dataDir, + TARGET_VERSION, + ), + requestedAt: new Date().toISOString(), + }), + "utf8", + ); + + await harness.service.resume(); + expect(harness.service.getState().scheduled).toEqual({ + targetVersion: TARGET_VERSION, + requestedAt: expect.any(String), + phase: "waiting", + }); + + harness.setThreadStatus("thr_busy", "idle"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + + it("discards a stale sentinel targeting the running version and prunes staging", async () => { + const harness = await createHarness(); + // Simulate a completed update: the sentinel targets the version that is + // now running, and an older staged install is still on disk. + await writeStagedPackage(harness.dataDir, CURRENT_VERSION); + await writeStagedPackage(harness.dataDir, "0.0.9"); + await writeFile( + formatSelfUpdateSentinelPath(harness.dataDir), + JSON.stringify({ + targetVersion: CURRENT_VERSION, + stagedPackageRoot: formatStagedPackageRoot( + harness.dataDir, + CURRENT_VERSION, + ), + requestedAt: new Date().toISOString(), + }), + "utf8", + ); + + await harness.service.resume(); + expect(harness.service.getState().scheduled).toBeNull(); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).rejects.toMatchObject({ code: "ENOENT" }); + // The running version's staged install survives (we may be running from + // it); the older one is pruned. + await expect( + stat(formatSelfUpdateStagingDir(harness.dataDir, CURRENT_VERSION)), + ).resolves.toBeDefined(); + await expect( + stat(formatSelfUpdateStagingDir(harness.dataDir, "0.0.9")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("resolveNpmInvocation", () => { + it("uses real npm from npm_execpath but never pnpm/yarn", async () => { + const { resolveNpmInvocation } = await import( + "../../src/services/system/self-update.js" + ); + expect( + resolveNpmInvocation({ npm_execpath: "/usr/lib/node_modules/npm/bin/npm-cli.js" }), + ).toEqual({ + command: process.execPath, + argsPrefix: ["/usr/lib/node_modules/npm/bin/npm-cli.js"], + }); + // pnpm sets npm_execpath to its own CLI, which rejects npm's flags. + expect( + resolveNpmInvocation({ npm_execpath: "/x/pnpm/bin/pnpm.cjs" }), + ).toEqual({ command: "npm", argsPrefix: [] }); + expect(resolveNpmInvocation({})).toEqual({ command: "npm", argsPrefix: [] }); + }); +}); diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index 7ba936fdad..d24b72ca0b 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -16,9 +16,19 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { + APP_SURFACE_DESKTOP, APP_SURFACE_ENV_NAME, APP_SURFACE_WEB, + parseAppSurface, } from "@bb/config/app-surface"; +import { + BB_SELF_UPDATE_EXIT_CODE, + BB_SELF_UPDATE_PROTOCOL_ENV_NAME, + BB_SELF_UPDATE_PROTOCOL_VERSION, + formatSelfUpdateSentinelPath, + selfUpdateSentinelSchema, + type SelfUpdateSentinel, +} from "@bb/config/self-update"; import { BB_APP_MANAGED_CONFIG_KEYS, bbAppManagedEnvFileSchema, @@ -337,6 +347,19 @@ interface RestartManagedProcessArgs { start: StartManagedProcess; } +/** Replacement start context + closures for a staged self-update. */ +export interface SelfUpdateSwap { + context: BbAppStartContext; + startDaemon: StartManagedProcess; + startServer: StartManagedProcess; +} + +/** + * Consumes the self-update sentinel (if any) and returns the swap to the + * staged version, or null when there is nothing valid to swap to. + */ +export type TrySelfUpdateSwapFn = () => Promise; + export interface SuperviseFullStackProcessesArgs { context: BbAppStartContext; delayMilliseconds: DelayMillisecondsFn; @@ -344,6 +367,8 @@ export interface SuperviseFullStackProcessesArgs { processes: ManagedFullStackProcesses; startDaemon: StartManagedProcess; startServer: StartManagedProcess; + /** Absent when the caller does not support self-update (tests, futures). */ + trySelfUpdateSwap?: TrySelfUpdateSwapFn; } export interface TerminateManagedFullStackProcessesArgs { @@ -400,6 +425,12 @@ interface CreateSharedEnvArgs { interface CreateServerEnvArgs { context: BbAppStartContext; env: NodeJS.ProcessEnv; + /** + * Whether this launcher will honor the self-update swap protocol for the + * spawned server. False for standalone bb-server (no supervision) and when + * running under the desktop shell (electron-updater owns updates there). + */ + selfUpdateProtocol: boolean; } interface CreateServerBaseEnvArgs { @@ -1109,7 +1140,23 @@ export function resolveBbAppStartContext( args: ResolveBbAppStartContextArgs, ): BbAppStartContext { const entrypointDir = dirname(fileURLToPath(args.entrypointUrl)); - const packageRoot = resolve(entrypointDir, ".."); + return resolveBbAppStartContextFromPackageRoot({ + env: args.env, + homeDir: args.homeDir, + packageRoot: resolve(entrypointDir, ".."), + }); +} + +export interface ResolveBbAppStartContextFromPackageRootArgs { + env: NodeJS.ProcessEnv; + homeDir: string; + packageRoot: string; +} + +export function resolveBbAppStartContextFromPackageRoot( + args: ResolveBbAppStartContextFromPackageRootArgs, +): BbAppStartContext { + const packageRoot = args.packageRoot; const dataDir = resolveDataDir({ env: args.env, homeDir: args.homeDir }); const serverPort = resolvePort({ defaultPort: BB_PROD_SERVER_PORT, @@ -1822,6 +1869,30 @@ async function pathExists(pathToCheck: string): Promise { } } +/** + * Read and delete the self-update sentinel. One-shot on purpose: a swap that + * fails must not retry on the next server exit, or a broken staged install + * would wedge the restart loop. + */ +async function consumeSelfUpdateSentinel( + dataDir: string, +): Promise { + const sentinelPath = formatSelfUpdateSentinelPath(dataDir); + let rawContents: string; + try { + rawContents = await readFile(sentinelPath, "utf8"); + } catch { + return null; + } + await unlink(sentinelPath).catch(() => undefined); + try { + return selfUpdateSentinelSchema.parse(JSON.parse(rawContents)); + } catch { + log(yellow("!"), "Ignoring malformed self-update sentinel"); + return null; + } +} + async function readPersistedHostId(dataDir: string): Promise { try { const value = ( @@ -2113,7 +2184,7 @@ function createSharedEnv(args: CreateSharedEnvArgs): NodeJS.ProcessEnv { } function createServerEnv(args: CreateServerEnvArgs): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { ...args.env, BB_APP_VERSION: args.context.appVersion, [APP_SURFACE_ENV_NAME]: APP_SURFACE_WEB, @@ -2122,6 +2193,12 @@ function createServerEnv(args: CreateServerEnvArgs): NodeJS.ProcessEnv { BB_SERVER_PORT: String(args.context.serverPort), NODE_ENV: "production", }; + if (args.selfUpdateProtocol) { + env[BB_SELF_UPDATE_PROTOCOL_ENV_NAME] = BB_SELF_UPDATE_PROTOCOL_VERSION; + } else { + delete env[BB_SELF_UPDATE_PROTOCOL_ENV_NAME]; + } + return env; } function createDaemonEnv( @@ -2296,6 +2373,7 @@ Usage: env: createServerEnv({ context: runtime.context, env: runtime.serverEnv, + selfUpdateProtocol: false, }), stdio: "inherit", }); @@ -2623,6 +2701,12 @@ export async function terminateManagedFullStackProcesses( export async function superviseFullStackProcesses( args: SuperviseFullStackProcessesArgs, ): Promise { + // Mutable: a successful self-update swap replaces the start context and + // closures, so later crash restarts relaunch the updated version. + let context = args.context; + let startServer = args.startServer; + let startDaemon = args.startDaemon; + while (!args.isShutdownRequested()) { const serverRun = args.processes.serverRun; const daemonRun = args.processes.daemonRun; @@ -2635,12 +2719,85 @@ export async function superviseFullStackProcesses( return "shutdown"; } - log( - yellow("!"), - `${formatManagedProcessLabel(exitedProcess.processName)} exited with ${formatProcessExitResult( - exitedProcess.result, - )} - restarting ${formatManagedProcessLabel(exitedProcess.processName)}`, - ); + if ( + exitedProcess.processName === "server" && + exitedProcess.result.code === BB_SELF_UPDATE_EXIT_CODE && + args.trySelfUpdateSwap !== undefined + ) { + if (args.processes.serverRun === serverRun) { + args.processes.serverRun = null; + } + const swap = await args.trySelfUpdateSwap(); + if (args.isShutdownRequested()) { + return "shutdown"; + } + if (swap !== null) { + log( + yellow("!"), + `Server stopped for a self-update - switching to bb-app ${swap.context.appVersion}`, + ); + await daemonRun.terminate("SIGTERM"); + if (args.processes.daemonRun === daemonRun) { + args.processes.daemonRun = null; + } + beginStep(`Starting bb-app ${swap.context.appVersion}`); + try { + await swap.startServer(); + await swap.startDaemon(); + context = swap.context; + startServer = swap.startServer; + startDaemon = swap.startDaemon; + endStep(green("✓"), `Updated to bb-app ${swap.context.appVersion}`); + continue; + } catch { + endStep( + red("✗"), + `bb-app ${swap.context.appVersion} failed to start - restarting the current version`, + ); + // A half-started new stack may be running (e.g. server healthy but + // daemon failed); tear it down before restarting the old version. + await terminateManagedFullStackProcesses({ + processes: args.processes, + signal: "SIGTERM", + }); + args.processes.serverRun = null; + args.processes.daemonRun = null; + const restartedServer = await restartManagedProcess({ + context, + delayMilliseconds: args.delayMilliseconds, + isShutdownRequested: args.isShutdownRequested, + processName: "server", + start: startServer, + }); + if (restartedServer === null) { + return "shutdown"; + } + const restartedDaemon = await restartManagedProcess({ + context, + delayMilliseconds: args.delayMilliseconds, + isShutdownRequested: args.isShutdownRequested, + processName: "daemon", + start: startDaemon, + }); + if (restartedDaemon === null) { + return "shutdown"; + } + continue; + } + } + log( + yellow("!"), + "Server exited for a self-update but no staged update was usable - restarting server", + ); + // Fall through to the normal restart path below. + } else { + log( + yellow("!"), + `${formatManagedProcessLabel(exitedProcess.processName)} exited with ${formatProcessExitResult( + exitedProcess.result, + )} - restarting ${formatManagedProcessLabel(exitedProcess.processName)}`, + ); + } if (exitedProcess.processName === "server") { if (args.processes.serverRun === serverRun) { @@ -2653,11 +2810,11 @@ export async function superviseFullStackProcesses( return "shutdown"; } const restartedServer = await restartManagedProcess({ - context: args.context, + context, delayMilliseconds: args.delayMilliseconds, isShutdownRequested: args.isShutdownRequested, processName: "server", - start: args.startServer, + start: startServer, }); if (restartedServer === null) { return "shutdown"; @@ -2675,11 +2832,11 @@ export async function superviseFullStackProcesses( return "shutdown"; } const restartedDaemon = await restartManagedProcess({ - context: args.context, + context, delayMilliseconds: args.delayMilliseconds, isShutdownRequested: args.isShutdownRequested, processName: "daemon", - start: args.startDaemon, + start: startDaemon, }); if (restartedDaemon === null) { return "shutdown"; @@ -2774,9 +2931,15 @@ export async function runBbApp( const context = runtime.context; const outputBuffer = createOutputBuffer(); + // Desktop owns updates through electron-updater; advertising the swap + // protocol there would fork the runtime version away from the bundle. + const selfUpdateProtocol = + parseAppSurface(runtime.env[APP_SURFACE_ENV_NAME] ?? "") !== + APP_SURFACE_DESKTOP; const serverEnv = createServerEnv({ context, env: runtime.serverEnv, + selfUpdateProtocol, }); const sharedEnv = createSharedEnv({ context, @@ -2883,6 +3046,57 @@ export async function runBbApp( process.stdout.write("\n"); log(" ", dim("Press Ctrl+C to stop")); + const trySelfUpdateSwap: TrySelfUpdateSwapFn = async () => { + const sentinel = await consumeSelfUpdateSentinel(context.dataDir); + if (sentinel === null) { + return null; + } + try { + // Resolve symlinks (e.g. /tmp -> /private/tmp on macOS): the daemon + // bundle only runs its main() when argv[1] string-equals its own + // resolved module path, so a symlinked spawn path exits silently. + const stagedPackageRoot = realpathSync(sentinel.stagedPackageRoot); + const stagedVersion = readBbAppPackageVersion(stagedPackageRoot); + if (stagedVersion !== sentinel.targetVersion) { + throw new Error( + `staged version ${stagedVersion} does not match target ${sentinel.targetVersion}`, + ); + } + const stagedContext = resolveBbAppStartContextFromPackageRoot({ + env: runtime.env, + homeDir: homedir(), + packageRoot: stagedPackageRoot, + }); + assertBbAppArtifacts(stagedContext); + const stagedServerEnv = createServerEnv({ + context: stagedContext, + env: runtime.serverEnv, + selfUpdateProtocol, + }); + return { + context: stagedContext, + startServer: () => + startFullStackServerProcess({ + context: stagedContext, + env: stagedServerEnv, + outputBuffer, + processes, + }), + startDaemon: () => + startFullStackDaemonProcess({ + autoJoinEnv, + context: stagedContext, + outputBuffer, + processes, + }), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(yellow("!"), `Ignoring staged self-update: ${message}`); + return null; + } + }; + outputBuffer.flush(); const supervisionResult = await superviseFullStackProcesses({ context, @@ -2891,6 +3105,7 @@ export async function runBbApp( processes, startDaemon, startServer, + trySelfUpdateSwap, }); await completeFullStackSupervision({ shutdownPromise, supervisionResult }); } catch (error) { diff --git a/packages/bb-app/test/index.test.ts b/packages/bb-app/test/index.test.ts index 862944235a..96de423eec 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -46,7 +46,9 @@ import type { ManagedProcessRun, NamedProcessExitResult, ProcessExitResult, + SelfUpdateSwap, } from "../src/launcher.js"; +import { BB_SELF_UPDATE_EXIT_CODE } from "@bb/config/self-update"; interface DelayArgs { ms: number; @@ -270,6 +272,43 @@ function createFakeSupervisor(): FakeSupervisor { }; } +interface FakeSwapTarget { + daemonRuns: FakeManagedProcessRun[]; + serverRuns: FakeManagedProcessRun[]; + swap: SelfUpdateSwap; +} + +function createFakeSwapTarget(supervisor: FakeSupervisor): FakeSwapTarget { + const serverRuns: FakeManagedProcessRun[] = []; + const daemonRuns: FakeManagedProcessRun[] = []; + const swap: SelfUpdateSwap = { + context: { + ...createTestStartContext(), + appVersion: "9.9.9-updated", + packageRoot: "/tmp/bb-app-test/self-update/9.9.9-updated", + }, + startServer: async () => { + const run = new FakeManagedProcessRun({ + id: `updated-server-${serverRuns.length + 1}`, + processName: "server", + }); + serverRuns.push(run); + supervisor.processes.serverRun = run; + return run; + }, + startDaemon: async () => { + const run = new FakeManagedProcessRun({ + id: `updated-daemon-${daemonRuns.length + 1}`, + processName: "daemon", + }); + daemonRuns.push(run); + supervisor.processes.daemonRun = run; + return run; + }, + }; + return { daemonRuns, serverRuns, swap }; +} + async function waitForProcessReplacement( args: WaitForProcessReplacementArgs, ): Promise { @@ -1312,6 +1351,147 @@ describe("bb-app launcher", () => { ); }); + it("swaps both children to the staged version on the self-update exit code", async () => { + const supervisor = createFakeSupervisor(); + const initialServerRun = supervisor.serverRuns[0]; + const initialDaemonRun = supervisor.daemonRuns[0]; + const updated = createFakeSwapTarget(supervisor); + let swapCalls = 0; + const supervision = superviseFullStackProcesses({ + context: createTestStartContext(), + delayMilliseconds: immediateDelay, + isShutdownRequested: supervisor.shutdownRequested, + processes: supervisor.processes, + startDaemon: supervisor.daemonStart, + startServer: supervisor.serverStart, + trySelfUpdateSwap: async () => { + swapCalls += 1; + return updated.swap; + }, + }); + + initialServerRun.exitWith({ + code: BB_SELF_UPDATE_EXIT_CODE, + signal: null, + }); + const swappedServerRun = await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: initialServerRun, + }); + const swappedDaemonRun = await waitForProcessReplacement({ + currentRun: () => supervisor.processes.daemonRun, + previousRun: initialDaemonRun, + }); + + expect(swapCalls).toBe(1); + expect(initialDaemonRun.terminationSignals).toEqual(["SIGTERM"]); + expect(swappedServerRun).toBe(updated.serverRuns[0]); + expect(swappedDaemonRun).toBe(updated.daemonRuns[0]); + // The original closures were not used to restart anything. + expect(supervisor.serverRuns).toHaveLength(1); + expect(supervisor.daemonRuns).toHaveLength(1); + + // A later crash restarts the *updated* version. + updated.serverRuns[0].exitWith({ code: 1, signal: null }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: updated.serverRuns[0], + }); + expect(updated.serverRuns).toHaveLength(2); + expect(supervisor.serverRuns).toHaveLength(1); + + await expect(stopFakeSupervisor(supervisor, supervision)).resolves.toBe( + "shutdown", + ); + }); + + it("restarts the server normally when the self-update swap is unavailable", async () => { + const supervisor = createFakeSupervisor(); + const initialServerRun = supervisor.serverRuns[0]; + const initialDaemonRun = supervisor.daemonRuns[0]; + let swapCalls = 0; + const supervision = superviseFullStackProcesses({ + context: createTestStartContext(), + delayMilliseconds: immediateDelay, + isShutdownRequested: supervisor.shutdownRequested, + processes: supervisor.processes, + startDaemon: supervisor.daemonStart, + startServer: supervisor.serverStart, + trySelfUpdateSwap: async () => { + swapCalls += 1; + return null; + }, + }); + + initialServerRun.exitWith({ + code: BB_SELF_UPDATE_EXIT_CODE, + signal: null, + }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: initialServerRun, + }); + + expect(swapCalls).toBe(1); + expect(supervisor.serverRuns).toHaveLength(2); + expect(supervisor.daemonRuns).toHaveLength(1); + expect(initialDaemonRun.running).toBe(true); + + await expect(stopFakeSupervisor(supervisor, supervision)).resolves.toBe( + "shutdown", + ); + }); + + it("restores the current version when the staged version fails to start", async () => { + const supervisor = createFakeSupervisor(); + const initialServerRun = supervisor.serverRuns[0]; + const initialDaemonRun = supervisor.daemonRuns[0]; + const updated = createFakeSwapTarget(supervisor); + const failingSwap: SelfUpdateSwap = { + context: updated.swap.context, + startServer: async () => { + throw new Error("staged server never became healthy"); + }, + startDaemon: updated.swap.startDaemon, + }; + const supervision = superviseFullStackProcesses({ + context: createTestStartContext(), + delayMilliseconds: immediateDelay, + isShutdownRequested: supervisor.shutdownRequested, + processes: supervisor.processes, + startDaemon: supervisor.daemonStart, + startServer: supervisor.serverStart, + trySelfUpdateSwap: async () => failingSwap, + }); + + initialServerRun.exitWith({ + code: BB_SELF_UPDATE_EXIT_CODE, + signal: null, + }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.serverRun, + previousRun: initialServerRun, + }); + await waitForProcessReplacement({ + currentRun: () => supervisor.processes.daemonRun, + previousRun: initialDaemonRun, + }); + + // The old daemon was stopped for the swap, then the *current* version's + // closures restarted both children. + expect(initialDaemonRun.terminationSignals).toEqual(["SIGTERM"]); + expect(supervisor.serverRuns).toHaveLength(2); + expect(supervisor.daemonRuns).toHaveLength(2); + expect(updated.serverRuns).toHaveLength(0); + expect(updated.daemonRuns).toHaveLength(0); + expect(supervisor.processes.serverRun).toBe(supervisor.serverRuns[1]); + expect(supervisor.processes.daemonRun).toBe(supervisor.daemonRuns[1]); + + await expect(stopFakeSupervisor(supervisor, supervision)).resolves.toBe( + "shutdown", + ); + }); + it("terminates both children without restarting during shutdown", async () => { const supervisor = createFakeSupervisor(); const initialServerRun = supervisor.serverRuns[0]; diff --git a/packages/config/package.json b/packages/config/package.json index 0bd9ae1d77..f78818a0db 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -73,6 +73,11 @@ "types": "./src/runtime.ts", "default": "./src/runtime.ts" }, + "./self-update": { + "source": "./src/self-update.ts", + "types": "./src/self-update.ts", + "default": "./src/self-update.ts" + }, "./objects": { "source": "./src/objects.ts", "types": "./src/objects.ts", diff --git a/packages/config/src/env-vars.ts b/packages/config/src/env-vars.ts index 70559e87f9..1e04bc0401 100644 --- a/packages/config/src/env-vars.ts +++ b/packages/config/src/env-vars.ts @@ -214,6 +214,13 @@ export const BB_TELEMETRY_ENV = defineEnvVar({ parse: parseBooleanEnvValue, }); +export const BB_SELF_UPDATE_PROTOCOL_ENV = defineEnvVar({ + description: + "Set by the bb-app launcher on the server child to advertise that it understands the self-update swap protocol. Internal; not a user knob.", + name: "BB_SELF_UPDATE_PROTOCOL", + parse: parseBooleanEnvValue, +}); + export const BB_FF_PLACEHOLDER_ENV = defineEnvVar({ description: "Permanent placeholder feature flag. Non-functional keep-alive so the flag system has at least one entry; do not gate behavior on it.", diff --git a/packages/config/src/self-update.ts b/packages/config/src/self-update.ts new file mode 100644 index 0000000000..22845c4ab4 --- /dev/null +++ b/packages/config/src/self-update.ts @@ -0,0 +1,68 @@ +import { join } from "node:path"; +import { z } from "zod"; + +/** + * Contract between the server and the bb-app launcher for in-place self + * updates ("update when agents finish"). + * + * Flow: the server stages an `npm install` of the target bb-app version under + * the data dir, writes the sentinel file, and — once no agents are running — + * exits with the reserved exit code. The launcher, seeing that exit code plus + * a valid sentinel, restarts the server and host daemon from the staged + * package root instead of the original install. + */ + +/** Server exit code that asks the launcher to swap to the staged version. */ +export const BB_SELF_UPDATE_EXIT_CODE = 75; + +/** + * Env var the bb-app launcher sets on the server child to advertise that it + * understands the self-update protocol. Absent under `bb-server`, dev runs, + * and the desktop shell (which updates through electron-updater instead), so + * old launchers can never be asked to perform a swap they don't understand. + */ +export const BB_SELF_UPDATE_PROTOCOL_ENV_NAME = "BB_SELF_UPDATE_PROTOCOL"; +export const BB_SELF_UPDATE_PROTOCOL_VERSION = "1"; + +export const selfUpdateSentinelSchema = z.object({ + /** bb-app version staged and ready to swap to. */ + targetVersion: z.string().min(1), + /** Absolute path of the staged bb-app package root (contains package.json). */ + stagedPackageRoot: z.string().min(1), + /** ISO timestamp of when the user scheduled the update. */ + requestedAt: z.string().min(1), +}); +export type SelfUpdateSentinel = z.infer; + +export function formatSelfUpdateSentinelPath(dataDir: string): string { + return join(dataDir, "self-update.json"); +} + +/** Root directory holding one staged install per version. */ +export function formatSelfUpdateStagingRoot(dataDir: string): string { + return join(dataDir, "self-update"); +} + +/** npm --prefix target for a staged version. */ +export function formatSelfUpdateStagingDir( + dataDir: string, + version: string, +): string { + return join(formatSelfUpdateStagingRoot(dataDir), version); +} + +/** Package root of a staged install: `/node_modules/bb-app`. */ +export function formatStagedPackageRoot( + dataDir: string, + version: string, +): string { + return join( + formatSelfUpdateStagingDir(dataDir, version), + "node_modules", + "bb-app", + ); +} + +export function parseSelfUpdateSentinel(value: unknown): SelfUpdateSentinel { + return selfUpdateSentinelSchema.parse(value); +} diff --git a/packages/config/src/server.ts b/packages/config/src/server.ts index c18da8673e..18b3fd57bb 100644 --- a/packages/config/src/server.ts +++ b/packages/config/src/server.ts @@ -16,6 +16,7 @@ import { BB_INHERITED_SKILLS_ROOTS_ENV, BB_INFERENCE_ENV, BB_POSTHOG_API_KEY_ENV, + BB_SELF_UPDATE_PROTOCOL_ENV, BB_TELEMETRY_ENV, BB_TRANSCRIPTION_ENV, DEFAULT_BB_APP_URL, @@ -45,6 +46,8 @@ export interface ServerConfig BB_INHERITED_SKILLS_ROOTS: string[]; BB_INFERENCE: string; BB_POSTHOG_API_KEY: string; + /** True when the parent bb-app launcher supports the self-update swap. */ + BB_SELF_UPDATE_PROTOCOL: boolean; BB_TELEMETRY: boolean; BB_TRANSCRIPTION: string; OPENAI_API_KEY: string; @@ -133,6 +136,12 @@ export function loadServerConfig( definition: BB_POSTHOG_API_KEY_ENV, env: loader.env, }), + BB_SELF_UPDATE_PROTOCOL: readEnvVarWithDefault({ + context: loader.context, + defaultValue: false, + definition: BB_SELF_UPDATE_PROTOCOL_ENV, + env: loader.env, + }), BB_TELEMETRY: readEnvVarWithDefault({ context: loader.context, defaultValue: DEFAULT_BB_TELEMETRY, diff --git a/packages/desktop-contract/src/info.ts b/packages/desktop-contract/src/info.ts index bd4c2e9624..eae90fdb90 100644 --- a/packages/desktop-contract/src/info.ts +++ b/packages/desktop-contract/src/info.ts @@ -4,6 +4,14 @@ import type { BbDesktopPopoutApi } from "./popout.js"; const isoUtcDateTimeSchema = z.iso.datetime(); +/** A pending "relaunch when agents finish" deferred update install. */ +export const bbDesktopDeferredInstallSchema = z.object({ + requestedAt: isoUtcDateTimeSchema, +}); +export type BbDesktopDeferredInstall = z.infer< + typeof bbDesktopDeferredInstallSchema +>; + export const bbDesktopInfoSchema = z.object({ lastCheckedAt: isoUtcDateTimeSchema.nullable(), latestVersion: z.string().min(1).nullable(), @@ -12,6 +20,17 @@ export const bbDesktopInfoSchema = z.object({ updateAvailable: z.boolean(), updateDownloaded: z.boolean(), version: z.string().min(1), + /** + * True when relaunching would interrupt agents on a shell-owned local + * runtime, so a deferred "relaunch when agents finish" install is offered. + * Absent on desktop shells that predate deferred installs. + */ + canDeferInstall: z.boolean().optional(), + /** + * The pending deferred install, or null when none is scheduled. Absent on + * desktop shells that predate deferred installs. + */ + deferredInstall: bbDesktopDeferredInstallSchema.nullable().optional(), }); export type BbDesktopInfo = z.infer; @@ -40,6 +59,14 @@ export interface BbDesktopApi extends BbDesktopInfo { checkForUpdates(): Promise; getInfo(): Promise; installUpdate(): Promise; + /** + * Defer the downloaded update: the main process polls the owned runtime and + * relaunches once no agents have been running for a quiet period. Optional + * for desktop shells that predate deferred installs. + */ + installUpdateWhenIdle?(): Promise; + /** Cancel a pending deferred install. Optional like installUpdateWhenIdle. */ + cancelDeferredInstall?(): Promise; onChange(listener: BbDesktopInfoChangeHandler): BbDesktopInfoUnsubscribe; /** * Subscribe to native desktop requests to open the current thread's secondary diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index c8cffc1676..6e16b17985 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -105,7 +105,34 @@ export const themeCatalogResponseSchema = z.object({ }); export type ThemeCatalogResponse = z.infer; -export const systemVersionResponseSchema = z.object({ +/** + * A scheduled "update when agents finish" self-update. + * `staging` = npm install of the target version is still running; + * `waiting` = staged and ready, waiting for all agents to go idle. + */ +export const systemSelfUpdateScheduledSchema = z.object({ + targetVersion: z.string(), + requestedAt: z.string(), + phase: z.enum(["staging", "waiting"]), +}); +export type SystemSelfUpdateScheduled = z.infer< + typeof systemSelfUpdateScheduledSchema +>; + +export const systemSelfUpdateStateSchema = z.object({ + /** + * True when the server runs under a bb-app launcher that can perform the + * staged-version swap. False under dev, standalone bb-server, and desktop. + */ + capable: z.boolean(), + scheduled: systemSelfUpdateScheduledSchema.nullable(), + /** Most recent schedule/staging failure since boot, or null. */ + lastError: z.string().nullable(), +}); +export type SystemSelfUpdateState = z.infer; + +/** Version/update info owned by the app-version service (no self-update state). */ +export const systemVersionInfoSchema = z.object({ /** Version of the running bb-app package, read from package.json. */ currentVersion: z.string(), /** Latest version published to npm, or null when the lookup is unavailable. */ @@ -119,8 +146,25 @@ export const systemVersionResponseSchema = z.object({ /** Command users should run to upgrade. Server-owned product policy. */ upgradeCommand: z.string(), }); +export type SystemVersionInfo = z.infer; + +export const systemVersionResponseSchema = systemVersionInfoSchema.extend({ + selfUpdate: systemSelfUpdateStateSchema, +}); export type SystemVersionResponse = z.infer; +/** + * Live agent load, for update tooling that must wait for agents to finish + * (the desktop shell's deferred relaunch polls this). + */ +export const systemAgentActivityResponseSchema = z.object({ + /** Threads currently starting, active, or stopping. */ + busyThreadCount: z.number().int().min(0), +}); +export type SystemAgentActivityResponse = z.infer< + typeof systemAgentActivityResponseSchema +>; + export const systemConfigReloadResponseSchema = z.object({ ok: z.literal(true), }); diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 38cf3031ab..bdbf8c74eb 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -105,7 +105,9 @@ import type { SystemConfigResponse, SystemExecutionOptionsQuery, SystemExecutionOptionsResponse, + SystemAgentActivityResponse, SystemProviderInfo, + SystemSelfUpdateState, SystemVersionResponse, SystemVoiceTranscriptionForm, SystemVoiceTranscriptionResponse, @@ -1048,6 +1050,27 @@ export const publicApiRoutes = { request: noRequest(), response: jsonResponse(), }), + // "Update when agents finish": stage the latest bb-app version and apply + // it once no agents are running. POST schedules (idempotent per version); + // DELETE cancels a pending schedule. Both return the resulting state. + scheduleSelfUpdate: defineRoute({ + path: "/system/update/schedule", + method: "post", + request: noRequest(), + response: jsonResponse(), + }), + cancelSelfUpdate: defineRoute({ + path: "/system/update/schedule", + method: "delete", + request: noRequest(), + response: jsonResponse(), + }), + agentActivity: defineRoute({ + path: "/system/agents/activity", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), }, }; diff --git a/tests/integration/helpers/harness.ts b/tests/integration/helpers/harness.ts index 10d2541141..4486f5dacd 100644 --- a/tests/integration/helpers/harness.ts +++ b/tests/integration/helpers/harness.ts @@ -31,6 +31,7 @@ import { } from "../../../apps/server/src/services/skills/builtin-skills-copy.js"; import { createAppVersionService } from "../../../apps/server/src/services/system/app-version.js"; import { createBbAppManagedConfigReloader } from "../../../apps/server/src/services/system/bb-app-managed-config.js"; +import { createSelfUpdateService } from "../../../apps/server/src/services/system/self-update.js"; import { createNoopTelemetryService } from "../../../apps/server/src/services/system/telemetry.js"; import { TerminalSessionLifecycle } from "../../../apps/server/src/services/terminals/terminal-session-lifecycle.js"; import type { @@ -229,6 +230,7 @@ async function startIntegrationServer( inheritedSkillsRootPaths: [], openAiApiKey: process.env.OPENAI_API_KEY ?? "test-openai-key", appUrl: "https://bb.example.test", + selfUpdateProtocol: false, serverPort: 0, threadStorageRootPath, transcriptionModel: "test/mock-transcription", @@ -270,6 +272,13 @@ async function startIntegrationServer( config, logger: testLogger, }); + const selfUpdate = createSelfUpdateService({ + appVersion, + config, + db, + logger: testLogger, + prepareShutdown: () => Promise.resolve(), + }); const { app, injectWebSocket } = createApp({ appVersion, bbAppManagedConfig, @@ -280,6 +289,7 @@ async function startIntegrationServer( logger: testLogger, machineAuth, pendingInteractions, + selfUpdate, telemetry, terminalSessions, watchInterests, From 7f0db204f332f720e6cca7647763cdedc427c9ca Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 14:14:04 -0700 Subject: [PATCH 2/9] Fork the update action: Update now when idle, defer while agents work The update toast now reads live agent activity: "Update now" when nothing is running, "Update when agents finish" while agents work. Both go through the same schedule endpoint; the server skips the 45s quiet period only when it has seen no busy agents since arming AND nothing is queued for the auto-send sweep to start (isServerAtRest), so a stale label can never kill an agent. Desktop mirrors this: plain Relaunch at rest, the deferred option only while agents are busy. Co-Authored-By: Claude Fable 5 --- apps/app/src/hooks/queries/query-keys.ts | 8 ++ apps/app/src/hooks/queries/system-queries.ts | 19 +++++ apps/app/src/hooks/useUpdateAvailableToast.ts | 56 +++++++++++-- apps/app/src/lib/api.ts | 9 ++ .../src/services/system/agent-activity.ts | 20 ++++- .../server/src/services/system/self-update.ts | 18 +++- apps/server/test/system/self-update.test.ts | 84 +++++++++++++++++-- 7 files changed, 196 insertions(+), 18 deletions(-) diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index b5cabf7ab8..9f8527d4e8 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -58,6 +58,7 @@ export const SYSTEM_PROVIDERS_QUERY_KEY = "systemProviders"; export const SYSTEM_CONFIG_QUERY_KEY = "systemConfig"; export const SYSTEM_EXECUTION_OPTIONS_QUERY_KEY = "systemExecutionOptions"; export const SYSTEM_VERSION_QUERY_KEY = "systemVersion"; +export const SYSTEM_AGENT_ACTIVITY_QUERY_KEY = "systemAgentActivity"; export const HOST_PROVIDER_CLI_STATUS_QUERY_KEY = "hostProviderCliStatus"; export const SYSTEM_USAGE_LIMITS_QUERY_KEY = "systemUsageLimits"; export const HOST_PATH_EXISTENCE_QUERY_KEY = "hostPathExistence"; @@ -422,6 +423,9 @@ export type SystemProvidersQueryKey = readonly [ ]; export type SystemConfigQueryKey = readonly [typeof SYSTEM_CONFIG_QUERY_KEY]; export type SystemVersionQueryKey = readonly [typeof SYSTEM_VERSION_QUERY_KEY]; +export type SystemAgentActivityQueryKey = readonly [ + typeof SYSTEM_AGENT_ACTIVITY_QUERY_KEY, +]; export type HostProviderCliStatusQueryKey = readonly [ typeof HOST_PROVIDER_CLI_STATUS_QUERY_KEY, string | null, @@ -1017,6 +1021,10 @@ export function systemVersionQueryKey(): SystemVersionQueryKey { return [SYSTEM_VERSION_QUERY_KEY]; } +export function systemAgentActivityQueryKey(): SystemAgentActivityQueryKey { + return [SYSTEM_AGENT_ACTIVITY_QUERY_KEY]; +} + export function hostProviderCliStatusQueryKey( hostId: string | null, ): HostProviderCliStatusQueryKey { diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 21e51dcd3d..64718edf61 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toRecord } from "@bb/core-ui"; import type { + SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemSelfUpdateState, @@ -13,6 +14,7 @@ import { applySelfUpdateStateToVersionCache } from "@/hooks/cache-owners/system- import { useSystemRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { hostProviderCliStatusQueryKey, + systemAgentActivityQueryKey, systemConfigQueryKey, systemExecutionOptionsQueryKey, systemUsageLimitsQueryKey, @@ -115,6 +117,23 @@ export function useSystemVersion(options?: QueryOptions) { }); } +/** Poll cadence for the update toast's busy/idle action label. */ +const AGENT_ACTIVITY_REFETCH_MS = 15_000; + +/** + * Live agent load, used to pick "Update now" vs "Update when agents finish" + * on the update toast. Only enabled while that choice is on screen. + */ +export function useSystemAgentActivity(options?: QueryOptions) { + return useQuery({ + queryKey: systemAgentActivityQueryKey(), + queryFn: ({ signal }) => api.getSystemAgentActivity(signal), + enabled: options?.enabled ?? true, + refetchInterval: AGENT_ACTIVITY_REFETCH_MS, + staleTime: 5_000, + }); +} + function useApplySelfUpdateState() { const queryClient = useQueryClient(); return (selfUpdate: SystemSelfUpdateState): void => { diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index 18198fc4f4..ef73d0f847 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -6,6 +6,7 @@ import { getBbDesktopInfo } from "@/lib/bb-desktop"; import { useCancelSelfUpdate, useScheduleSelfUpdate, + useSystemAgentActivity, useSystemVersion, } from "./queries/system-queries"; @@ -103,6 +104,22 @@ export function useUpdateAvailableToast(): void { const { data } = useSystemVersion(); const scheduleSelfUpdate = useScheduleSelfUpdate(); const cancelSelfUpdate = useCancelSelfUpdate(); + // Live agent load decides the action label: "Update now" when nothing is + // running, "Update when agents finish" otherwise. Only polled while the + // choice is actually on screen. + const activityEnabled = + getBbDesktopInfo() === null && + data !== undefined && + !data.isDevelopment && + data.updateAvailable && + data.selfUpdate.capable && + data.selfUpdate.scheduled === null; + const { data: agentActivity } = useSystemAgentActivity({ + enabled: activityEnabled, + }); + // Unknown activity reads as busy: the deferred label is the safe default. + const agentsBusy = + agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; const shownForVersionRef = useRef(null); /** id of the scheduled-update toast currently on screen (version + phase). */ const scheduledToastKeyRef = useRef(null); @@ -193,7 +210,12 @@ export function useUpdateAvailableToast(): void { if (latestVersion === null) { return; } - if (shownForVersionRef.current === latestVersion) { + // Keyed on busy state too, so the action label flips live when agents + // start or finish while the toast is up. + const availableKey = selfUpdate.capable + ? `${latestVersion}:${agentsBusy ? "busy" : "idle"}` + : latestVersion; + if (shownForVersionRef.current === availableKey) { return; } if ( @@ -202,10 +224,10 @@ export function useUpdateAvailableToast(): void { storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, }) ) { - shownForVersionRef.current = latestVersion; + shownForVersionRef.current = availableKey; return; } - shownForVersionRef.current = latestVersion; + shownForVersionRef.current = availableKey; const availableToastId = `bb-update-available:${latestVersion}`; appToast.message("bb-app update available", { id: availableToastId, @@ -216,7 +238,9 @@ export function useUpdateAvailableToast(): void { ...(selfUpdate.capable ? { action: { - label: "Update when agents finish", + // Same endpoint either way; the server skips the wait when + // nothing is running or queued. The label just tells the truth. + label: agentsBusy ? "Update when agents finish" : "Update now", onClick: () => { scheduleMutate(undefined, { onError: (error) => { @@ -248,12 +272,24 @@ export function useUpdateAvailableToast(): void { }); }, }); - }, [data, scheduleMutate, cancelMutate]); + }, [data, agentsBusy, scheduleMutate, cancelMutate]); } export function useDesktopUpdateAvailableToast(): void { const [desktopApi, setDesktopApi] = useState(null); const [desktopInfo, setDesktopInfo] = useState(null); + // The SPA in the desktop shell talks to the shell-owned server, so the same + // activity endpoint decides "Relaunch now" vs the deferred option. + const activityEnabled = + desktopInfo !== null && + desktopInfo.updateDownloaded && + (desktopInfo.deferredInstall ?? null) === null && + desktopInfo.canDeferInstall === true; + const { data: agentActivity } = useSystemAgentActivity({ + enabled: activityEnabled, + }); + const agentsBusy = + agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; const shownForVersionRef = useRef(null); useEffect(() => { @@ -323,11 +359,13 @@ export function useDesktopUpdateAvailableToast(): void { } // Only offer "when agents finish" when the shell owns a local runtime a - // relaunch would interrupt and is new enough to support deferral. + // relaunch would interrupt, it's new enough to support deferral, and + // agents are actually working; at rest a plain relaunch loses nothing. const canDefer = desktopInfo.canDeferInstall === true && - typeof desktopApi.installUpdateWhenIdle === "function"; - const readyKey = `${latestVersion}:ready`; + typeof desktopApi.installUpdateWhenIdle === "function" && + agentsBusy; + const readyKey = `${latestVersion}:ready:${canDefer ? "busy" : "idle"}`; if (shownForVersionRef.current === readyKey) { return; } @@ -361,5 +399,5 @@ export function useDesktopUpdateAvailableToast(): void { } : {}), }); - }, [desktopApi, desktopInfo]); + }, [desktopApi, desktopInfo, agentsBusy]); } diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index c6da5f17d1..4fb4b0b517 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -45,6 +45,7 @@ import type { SendQueuedMessageResponse, SetQueuedMessageGroupBoundaryRequest, SendMessageRequest, + SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, @@ -1746,6 +1747,14 @@ export async function getSystemVersion( ); } +export async function getSystemAgentActivity( + signal?: AbortSignal, +): Promise { + return request( + apiClient.system.agents.activity.$get({}, requestOptions(signal)), + ); +} + export async function scheduleSelfUpdate(): Promise { return request( apiClient.system.update.schedule.$post({}), diff --git a/apps/server/src/services/system/agent-activity.ts b/apps/server/src/services/system/agent-activity.ts index 4543f0b851..8d03002e53 100644 --- a/apps/server/src/services/system/agent-activity.ts +++ b/apps/server/src/services/system/agent-activity.ts @@ -1,5 +1,10 @@ import { and, count, inArray, isNull } from "drizzle-orm"; -import { threads, type DbQueryConnection } from "@bb/db"; +import { + listIdleThreadsWithQueuedMessages, + threads, + type DbConnection, + type DbQueryConnection, +} from "@bb/db"; /** Thread statuses that count as "an agent is running". */ export const BUSY_THREAD_STATUSES = ["starting", "active", "stopping"] as const; @@ -22,3 +27,16 @@ export function countBusyThreads(db: DbQueryConnection): number { .get(); return row?.value ?? 0; } + +/** + * True when a restart right now cannot interrupt or orphan agent work: no + * busy threads, and no idle thread holding queued messages that the 10s + * auto-send sweep is about to start. This is the gate for skipping the + * update quiet period entirely ("Update now"). + */ +export function isServerAtRest(db: DbConnection): boolean { + return ( + countBusyThreads(db) === 0 && + listIdleThreadsWithQueuedMessages(db).length === 0 + ); +} diff --git a/apps/server/src/services/system/self-update.ts b/apps/server/src/services/system/self-update.ts index 73580f78d7..4a641969b1 100644 --- a/apps/server/src/services/system/self-update.ts +++ b/apps/server/src/services/system/self-update.ts @@ -13,14 +13,14 @@ import { selfUpdateSentinelSchema, type SelfUpdateSentinel, } from "@bb/config/self-update"; -import type { DbQueryConnection } from "@bb/db"; +import type { DbConnection } from "@bb/db"; import type { SystemSelfUpdateScheduled, SystemSelfUpdateState, } from "@bb/server-contract"; import { ApiError } from "../../errors.js"; import type { ServerLogger, ServerRuntimeConfig } from "../../types.js"; -import { countBusyThreads } from "./agent-activity.js"; +import { countBusyThreads, isServerAtRest } from "./agent-activity.js"; import type { AppVersionService } from "./app-version.js"; const DEFAULT_POLL_INTERVAL_MS = 15_000; @@ -59,7 +59,7 @@ export interface CreateSelfUpdateServiceArgs { ServerRuntimeConfig, "appVersion" | "dataDir" | "isDevelopment" | "selfUpdateProtocol" >; - db: DbQueryConnection; + db: DbConnection; logger: ServerLogger; /** Runs the graceful server shutdown before the process exits for the swap. */ prepareShutdown: () => Promise; @@ -206,6 +206,9 @@ export function createSelfUpdateService( let scheduled: SystemSelfUpdateScheduled | null = null; let lastError: string | null = null; let idleSince: number | null = null; + // Set once any busy agent is seen after arming; from then on the full + // quiet period applies (mid-chain idle gaps look like rest otherwise). + let busyObservedSinceArm = false; let watcherHandle: ReturnType | null = null; let exiting = false; // Bumped on cancel/re-schedule so an in-flight staging install of a stale @@ -233,6 +236,7 @@ export function createSelfUpdateService( return; } idleSince = null; + busyObservedSinceArm = false; watcherHandle = setInterval(() => { try { watcherTick(); @@ -253,9 +257,17 @@ export function createSelfUpdateService( return; } if (hasBusyThreads()) { + busyObservedSinceArm = true; idleSince = null; return; } + // "Update now": scheduled while nothing was running and nothing is + // queued to start — no mid-chain gap to protect, so skip the quiet + // period. Once any busy agent has been seen, the full period applies. + if (!busyObservedSinceArm && isServerAtRest(db)) { + triggerExitForSwap(); + return; + } if (idleSince === null) { idleSince = now(); return; diff --git a/apps/server/test/system/self-update.test.ts b/apps/server/test/system/self-update.test.ts index 93e510361e..30bea73d3c 100644 --- a/apps/server/test/system/self-update.test.ts +++ b/apps/server/test/system/self-update.test.ts @@ -9,7 +9,13 @@ import { formatSelfUpdateStagingDir, formatStagedPackageRoot, } from "@bb/config/self-update"; -import { projects, threads } from "@bb/db"; +import { + environments, + hosts, + projects, + queuedThreadMessages, + threads, +} from "@bb/db"; import type { ThreadStatus } from "@bb/domain"; import type { SystemVersionInfo } from "@bb/server-contract"; import { initDb } from "../../src/db.js"; @@ -26,6 +32,7 @@ const TARGET_VERSION = "0.0.11"; interface HarnessOverrides { latestVersion?: string | null; + quietPeriodMs?: number; selfUpdateProtocol?: boolean; runStagingInstall?: (args: StagingInstallArgs) => Promise; } @@ -102,19 +109,24 @@ async function createHarness(overrides: HarnessOverrides = {}) { prepareShutdown, exitProcess, pollIntervalMs: 5, - quietPeriodMs: 25, + quietPeriodMs: overrides.quietPeriodMs ?? 25, runStagingInstall, }; const service = createSelfUpdateService(serviceArgs); cleanups.push(async () => service.stop()); - function insertThread(id: string, status: ThreadStatus): void { + function insertThread( + id: string, + status: ThreadStatus, + environmentId?: string, + ): void { db.insert(threads) .values({ id, projectId: "proj_1", providerId: "test-provider", status, + environmentId: environmentId ?? null, latestAttentionAt: now, createdAt: now, updatedAt: now, @@ -122,6 +134,45 @@ async function createHarness(overrides: HarnessOverrides = {}) { .run(); } + /** An idle thread holding a queued follow-up the auto-send sweep would start. */ + function insertIdleThreadWithQueuedMessage(threadId: string): void { + db.insert(hosts) + .values({ + id: "host_1", + name: "Test Host", + type: "persistent", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .run(); + db.insert(environments) + .values({ + id: `env_${threadId}`, + projectId: "proj_1", + hostId: "host_1", + workspaceProvisionType: "unmanaged", + createdAt: now, + updatedAt: now, + }) + .run(); + insertThread(threadId, "idle", `env_${threadId}`); + db.insert(queuedThreadMessages) + .values({ + id: `qmsg_${threadId}`, + threadId, + content: "queued follow-up", + model: "test/mock-model", + reasoningLevel: "medium", + permissionMode: "workspace-write", + serviceTier: "standard", + sortKey: "V", + createdAt: now, + updatedAt: now, + }) + .run(); + } + function setThreadStatus(id: string, status: ThreadStatus): void { db.update(threads).set({ status }).where(eq(threads.id, id)).run(); } @@ -130,6 +181,7 @@ async function createHarness(overrides: HarnessOverrides = {}) { dataDir, db, exitProcess, + insertIdleThreadWithQueuedMessage, insertThread, prepareShutdown, service, @@ -147,8 +199,9 @@ async function waitForWaitingPhase( } describe("self-update service", () => { - it("stages the update, writes the sentinel, and exits once agents stay idle", async () => { - const harness = await createHarness(); + it("stages, writes the sentinel, and exits immediately when nothing is running or queued", async () => { + // A quiet period far longer than the test proves the at-rest fast path. + const harness = await createHarness({ quietPeriodMs: 60_000 }); const state = await harness.service.schedule(); expect(state.scheduled).toEqual({ targetVersion: TARGET_VERSION, @@ -169,6 +222,27 @@ describe("self-update service", () => { expect(harness.prepareShutdown).toHaveBeenCalledTimes(1); }); + it("does not skip the quiet period while a queued follow-up is pending", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertIdleThreadWithQueuedMessage("thr_queued"); + await harness.service.schedule(); + await waitForWaitingPhase(harness.service); + + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + }); + + it("does not skip the quiet period once agents have been seen working", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule(); + await waitForWaitingPhase(harness.service); + + harness.setThreadStatus("thr_busy", "idle"); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(harness.exitProcess).not.toHaveBeenCalled(); + }); + it("waits for busy threads to finish before exiting", async () => { const harness = await createHarness(); harness.insertThread("thr_busy", "active"); From c36f1ca54c4e509487b663c649d8625a2998e68d Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 14:24:16 -0700 Subject: [PATCH 3/9] Always offer Update now, escalating to interrupt agents when they work The schedule endpoint gains an explicit mode: "when-idle" defers behind the idle watcher (with the at-rest fast path), "now" applies as soon as staging completes, interrupting running agents at the user's request. While agents work the web toast offers both "Update when agents finish" and "Update now"; re-posting "now" escalates an already-waiting schedule. At rest the single "Update now" button keeps mode when-idle since the fast path is already immediate but still backs off if an agent starts during staging. Crash resume stays conservative (when-idle). Scheduling from the toast no longer records a version dismissal, so cancelling brings the offer back. Co-Authored-By: Claude Fable 5 --- apps/app/src/hooks/queries/system-queries.ts | 3 +- apps/app/src/hooks/useUpdateAvailableToast.ts | 109 ++++++++++++++---- apps/app/src/lib/api.ts | 7 +- apps/server/src/routes/system.ts | 4 +- .../server/src/services/system/self-update.ts | 37 +++++- apps/server/test/system/self-update.test.ts | 52 +++++++-- packages/server-contract/src/api/system.ts | 21 +++- packages/server-contract/src/public-api.ts | 14 ++- 8 files changed, 198 insertions(+), 49 deletions(-) diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 64718edf61..38ede3843a 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -4,6 +4,7 @@ import type { SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, + SystemSelfUpdateMode, SystemSelfUpdateState, SystemVersionResponse, } from "@bb/server-contract"; @@ -144,7 +145,7 @@ function useApplySelfUpdateState() { export function useScheduleSelfUpdate() { const applySelfUpdateState = useApplySelfUpdateState(); return useMutation({ - mutationFn: () => api.scheduleSelfUpdate(), + mutationFn: (mode: SystemSelfUpdateMode) => api.scheduleSelfUpdate(mode), onSuccess: applySelfUpdateState, }); } diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index ef73d0f847..cf9868ff8b 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -61,6 +61,23 @@ function markDismissedForVersion(args: VersionDismissalArgs): void { } } +/** + * Scheduling dismisses the toast programmatically, which also fires + * onDismiss and records a dismissal; clear it so cancelling the schedule + * brings the update offer back. + */ +function clearDismissedForVersion(args: VersionDismissalArgs): void { + const storage = getLocalStorage(); + if (storage === null) { + return; + } + try { + storage.removeItem(`${args.storageKeyPrefix}${args.latestVersion}`); + } catch { + // Ignore; worst case the toast stays hidden until reload. + } +} + function appUpdateDescription(latestVersion: string): string { return `${latestVersion} is available. Restart bb-app to update.`; } @@ -72,6 +89,9 @@ function scheduledUpdateToastId(targetVersion: string): string { function scheduledUpdateDescription( scheduled: SystemSelfUpdateScheduled, ): string { + if (scheduled.mode === "now") { + return `Updating to ${scheduled.targetVersion} now…`; + } return scheduled.phase === "staging" ? `Preparing update to ${scheduled.targetVersion}…` : `bb will update to ${scheduled.targetVersion} once no agents are running.`; @@ -155,7 +175,7 @@ export function useUpdateAvailableToast(): void { // Allow the "update available" toast to come back if this schedule is // cancelled later. shownForVersionRef.current = null; - const toastKey = `${scheduled.targetVersion}:${scheduled.phase}`; + const toastKey = `${scheduled.targetVersion}:${scheduled.phase}:${scheduled.mode}`; if (scheduledToastKeyRef.current === toastKey) { return; } @@ -229,6 +249,23 @@ export function useUpdateAvailableToast(): void { } shownForVersionRef.current = availableKey; const availableToastId = `bb-update-available:${latestVersion}`; + const scheduleUpdate = (mode: "when-idle" | "now"): void => { + scheduleMutate(mode, { + onError: (error) => { + appToast.error("Could not schedule the update", { + description: + error instanceof Error ? error.message : String(error), + }); + }, + }); + appToast.dismiss(availableToastId); + // Programmatic dismissal above also fires onDismiss; scheduling is not + // a dismissal, so undo the recorded one. + clearDismissedForVersion({ + latestVersion, + storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, + }); + }; appToast.message("bb-app update available", { id: availableToastId, description: selfUpdate.capable @@ -236,35 +273,57 @@ export function useUpdateAvailableToast(): void { : appUpdateDescription(latestVersion), duration: Infinity, ...(selfUpdate.capable - ? { - action: { - // Same endpoint either way; the server skips the wait when - // nothing is running or queued. The label just tells the truth. - label: agentsBusy ? "Update when agents finish" : "Update now", + ? agentsBusy + ? { + // Both choices while agents work: defer (safe default) or an + // explicit immediate update that interrupts them. Dismissal + // falls back to swiping the toast away. + action: { + label: "Update when agents finish", + onClick: () => { + scheduleUpdate("when-idle"); + }, + }, + cancel: { + label: "Update now", + onClick: () => { + scheduleUpdate("now"); + }, + }, + } + : { + // At rest, when-idle's fast path already applies immediately — + // and unlike mode "now" it still backs off if an agent starts + // during staging. + action: { + label: "Update now", + onClick: () => { + scheduleUpdate("when-idle"); + }, + }, + cancel: { + label: "Dismiss", + onClick: () => { + markDismissedForVersion({ + latestVersion, + storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, + }); + appToast.dismiss(availableToastId); + }, + }, + } + : { + cancel: { + label: "Dismiss", onClick: () => { - scheduleMutate(undefined, { - onError: (error) => { - appToast.error("Could not schedule the update", { - description: - error instanceof Error ? error.message : String(error), - }); - }, + markDismissedForVersion({ + latestVersion, + storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, }); appToast.dismiss(availableToastId); }, }, - } - : {}), - cancel: { - label: "Dismiss", - onClick: () => { - markDismissedForVersion({ - latestVersion, - storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, - }); - appToast.dismiss(availableToastId); - }, - }, + }), onDismiss: () => { markDismissedForVersion({ latestVersion, diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index 4fb4b0b517..fec71aca83 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -49,6 +49,7 @@ import type { SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, + SystemSelfUpdateMode, SystemSelfUpdateState, SystemVersionResponse, TimelinePaginationCursor, @@ -1755,9 +1756,11 @@ export async function getSystemAgentActivity( ); } -export async function scheduleSelfUpdate(): Promise { +export async function scheduleSelfUpdate( + mode: SystemSelfUpdateMode, +): Promise { return request( - apiClient.system.update.schedule.$post({}), + apiClient.system.update.schedule.$post({ json: { mode } }), ); } diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 1312993892..819f9e0b9a 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -164,8 +164,8 @@ export function registerSystemRoutes( }), ); - post(routes.scheduleSelfUpdate, async (context) => - context.json(await deps.selfUpdate.schedule()), + post(routes.scheduleSelfUpdate, async (context, payload) => + context.json(await deps.selfUpdate.schedule(payload.mode)), ); del(routes.cancelSelfUpdate, async (context) => diff --git a/apps/server/src/services/system/self-update.ts b/apps/server/src/services/system/self-update.ts index 4a641969b1..25ee173390 100644 --- a/apps/server/src/services/system/self-update.ts +++ b/apps/server/src/services/system/self-update.ts @@ -15,6 +15,7 @@ import { } from "@bb/config/self-update"; import type { DbConnection } from "@bb/db"; import type { + SystemSelfUpdateMode, SystemSelfUpdateScheduled, SystemSelfUpdateState, } from "@bb/server-contract"; @@ -46,7 +47,7 @@ export type RunStagingInstallFn = (args: StagingInstallArgs) => Promise; export interface SelfUpdateService { getState(): SystemSelfUpdateState; - schedule(): Promise; + schedule(mode: SystemSelfUpdateMode): Promise; cancel(): Promise; /** Adopt a sentinel left by a previous run and clean stale staged installs. */ resume(): Promise; @@ -361,7 +362,18 @@ export function createSelfUpdateService( stagedPackageRoot, requestedAt, }); - scheduled = { targetVersion, requestedAt, phase: "waiting" }; + // Re-read the mode: an "Update now" click may have escalated the + // schedule while npm was installing. + const mode = scheduled?.mode ?? "when-idle"; + scheduled = { targetVersion, requestedAt, phase: "waiting", mode }; + if (mode === "now") { + logger.info( + { targetVersion }, + "Self-update staged - applying immediately at user request", + ); + triggerExitForSwap(); + return; + } logger.info( { targetVersion }, "Self-update staged - waiting for agents to finish", @@ -384,7 +396,9 @@ export function createSelfUpdateService( } } - async function schedule(): Promise { + async function schedule( + mode: SystemSelfUpdateMode, + ): Promise { if (!capable) { throw new ApiError( 409, @@ -398,6 +412,18 @@ export function createSelfUpdateService( } const targetVersion = versionInfo.latestVersion; if (scheduled !== null && scheduled.targetVersion === targetVersion) { + // "Update now" escalates a pending schedule; the reverse never + // downgrades an explicit immediate request. + if (mode === "now" && scheduled.mode === "when-idle") { + scheduled = { ...scheduled, mode: "now" }; + if (scheduled.phase === "waiting") { + logger.info( + { targetVersion }, + "Applying scheduled self-update immediately at user request", + ); + triggerExitForSwap(); + } + } return getState(); } if (scheduled !== null) { @@ -406,7 +432,7 @@ export function createSelfUpdateService( lastError = null; const requestedAt = new Date(now()).toISOString(); - scheduled = { targetVersion, requestedAt, phase: "staging" }; + scheduled = { targetVersion, requestedAt, phase: "staging", mode }; const generation = scheduleGeneration; void stageAndArm(targetVersion, requestedAt, generation); return getState(); @@ -464,10 +490,13 @@ export function createSelfUpdateService( .catch(() => false); } if (targetIsNewer && stagedVersionMatches) { + // The sentinel does not persist the mode; resume conservatively as + // when-idle rather than surprise-restarting agents after a crash. scheduled = { targetVersion: sentinel.targetVersion, requestedAt: sentinel.requestedAt, phase: "waiting", + mode: "when-idle", }; logger.info( { targetVersion: sentinel.targetVersion }, diff --git a/apps/server/test/system/self-update.test.ts b/apps/server/test/system/self-update.test.ts index 30bea73d3c..e810094c30 100644 --- a/apps/server/test/system/self-update.test.ts +++ b/apps/server/test/system/self-update.test.ts @@ -202,11 +202,12 @@ describe("self-update service", () => { it("stages, writes the sentinel, and exits immediately when nothing is running or queued", async () => { // A quiet period far longer than the test proves the at-rest fast path. const harness = await createHarness({ quietPeriodMs: 60_000 }); - const state = await harness.service.schedule(); + const state = await harness.service.schedule("when-idle"); expect(state.scheduled).toEqual({ targetVersion: TARGET_VERSION, requestedAt: expect.any(String), phase: "staging", + mode: "when-idle", }); await waitForWaitingPhase(harness.service); @@ -225,17 +226,49 @@ describe("self-update service", () => { it("does not skip the quiet period while a queued follow-up is pending", async () => { const harness = await createHarness({ quietPeriodMs: 60_000 }); harness.insertIdleThreadWithQueuedMessage("thr_queued"); - await harness.service.schedule(); + await harness.service.schedule("when-idle"); await waitForWaitingPhase(harness.service); await new Promise((resolve) => setTimeout(resolve, 150)); expect(harness.exitProcess).not.toHaveBeenCalled(); }); + it("mode now applies straight after staging even while agents are busy", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertThread("thr_busy", "active"); + const state = await harness.service.schedule("now"); + expect(state.scheduled?.mode).toBe("now"); + + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + await expect( + stat(formatSelfUpdateSentinelPath(harness.dataDir)), + ).resolves.toBeDefined(); + }); + + it("update-now escalates a deferred schedule that is waiting on agents", async () => { + const harness = await createHarness({ quietPeriodMs: 60_000 }); + harness.insertThread("thr_busy", "active"); + await harness.service.schedule("when-idle"); + await waitForWaitingPhase(harness.service); + expect(harness.exitProcess).not.toHaveBeenCalled(); + + const state = await harness.service.schedule("now"); + expect(state.scheduled?.mode).toBe("now"); + await vi.waitFor(() => { + expect(harness.exitProcess).toHaveBeenCalledWith( + BB_SELF_UPDATE_EXIT_CODE, + ); + }); + }); + it("does not skip the quiet period once agents have been seen working", async () => { const harness = await createHarness({ quietPeriodMs: 60_000 }); harness.insertThread("thr_busy", "active"); - await harness.service.schedule(); + await harness.service.schedule("when-idle"); await waitForWaitingPhase(harness.service); harness.setThreadStatus("thr_busy", "idle"); @@ -246,7 +279,7 @@ describe("self-update service", () => { it("waits for busy threads to finish before exiting", async () => { const harness = await createHarness(); harness.insertThread("thr_busy", "active"); - await harness.service.schedule(); + await harness.service.schedule("when-idle"); await waitForWaitingPhase(harness.service); // Comfortably past the quiet period while the thread is still active. @@ -264,7 +297,7 @@ describe("self-update service", () => { it("does not exit while any agent is busy, even across handoffs", async () => { const harness = await createHarness(); harness.insertThread("thr_gate", "active"); - await harness.service.schedule(); + await harness.service.schedule("when-idle"); await waitForWaitingPhase(harness.service); // One agent finishes while another starts in the same instant: the @@ -285,7 +318,7 @@ describe("self-update service", () => { it("cancel removes the sentinel and staged install and stops the watcher", async () => { const harness = await createHarness(); harness.insertThread("thr_busy", "active"); - await harness.service.schedule(); + await harness.service.schedule("when-idle"); await waitForWaitingPhase(harness.service); const state = await harness.service.cancel(); @@ -308,7 +341,7 @@ describe("self-update service", () => { throw new Error("npm exploded"); }, }); - await harness.service.schedule(); + await harness.service.schedule("when-idle"); await vi.waitFor(() => { expect(harness.service.getState().scheduled).toBeNull(); }); @@ -320,12 +353,12 @@ describe("self-update service", () => { it("rejects scheduling when no update is available or when not capable", async () => { const upToDate = await createHarness({ latestVersion: CURRENT_VERSION }); - await expect(upToDate.service.schedule()).rejects.toMatchObject({ + await expect(upToDate.service.schedule("when-idle")).rejects.toMatchObject({ body: { code: "no_update_available" }, }); const incapable = await createHarness({ selfUpdateProtocol: false }); - await expect(incapable.service.schedule()).rejects.toBeInstanceOf(ApiError); + await expect(incapable.service.schedule("when-idle")).rejects.toBeInstanceOf(ApiError); expect(incapable.service.getState().capable).toBe(false); }); @@ -351,6 +384,7 @@ describe("self-update service", () => { targetVersion: TARGET_VERSION, requestedAt: expect.any(String), phase: "waiting", + mode: "when-idle", }); harness.setThreadStatus("thr_busy", "idle"); diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index 6e16b17985..d4b6321d02 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -106,14 +106,31 @@ export const themeCatalogResponseSchema = z.object({ export type ThemeCatalogResponse = z.infer; /** - * A scheduled "update when agents finish" self-update. + * How a scheduled self-update applies: `when-idle` waits for agents to + * finish; `now` applies as soon as staging completes, interrupting any + * running agents (the user explicitly chose that). + */ +export const systemSelfUpdateModeSchema = z.enum(["when-idle", "now"]); +export type SystemSelfUpdateMode = z.infer; + +export const systemSelfUpdateScheduleRequestSchema = z.object({ + mode: systemSelfUpdateModeSchema, +}); +export type SystemSelfUpdateScheduleRequest = z.infer< + typeof systemSelfUpdateScheduleRequestSchema +>; + +/** + * A scheduled self-update. * `staging` = npm install of the target version is still running; - * `waiting` = staged and ready, waiting for all agents to go idle. + * `waiting` = staged and ready, waiting for all agents to go idle + * (mode `now` skips `waiting` and applies straight after staging). */ export const systemSelfUpdateScheduledSchema = z.object({ targetVersion: z.string(), requestedAt: z.string(), phase: z.enum(["staging", "waiting"]), + mode: systemSelfUpdateModeSchema, }); export type SystemSelfUpdateScheduled = z.infer< typeof systemSelfUpdateScheduledSchema diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index bdbf8c74eb..ea73f08a86 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -107,6 +107,7 @@ import type { SystemExecutionOptionsResponse, SystemAgentActivityResponse, SystemProviderInfo, + SystemSelfUpdateScheduleRequest, SystemSelfUpdateState, SystemVersionResponse, SystemVoiceTranscriptionForm, @@ -200,6 +201,7 @@ import { setQueuedMessageGroupBoundaryRequestSchema, sendQueuedMessageRequestSchema, systemExecutionOptionsQuerySchema, + systemSelfUpdateScheduleRequestSchema, threadEventWaitQuerySchema, threadEventsQuerySchema, threadFilesRawQuerySchema, @@ -1050,13 +1052,17 @@ export const publicApiRoutes = { request: noRequest(), response: jsonResponse(), }), - // "Update when agents finish": stage the latest bb-app version and apply - // it once no agents are running. POST schedules (idempotent per version); - // DELETE cancels a pending schedule. Both return the resulting state. + // Stage the latest bb-app version and apply it either once no agents are + // running (mode "when-idle") or as soon as it is staged (mode "now", + // interrupting running agents). POST schedules (idempotent per version; + // re-posting "now" escalates a waiting schedule); DELETE cancels a + // pending schedule. Both return the resulting state. scheduleSelfUpdate: defineRoute({ path: "/system/update/schedule", method: "post", - request: noRequest(), + request: jsonRequest( + systemSelfUpdateScheduleRequestSchema, + ), response: jsonResponse(), }), cancelSelfUpdate: defineRoute({ From 8b9b0a644e6f6acc2596c7951a70195232a6b2ca Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 14:37:49 -0700 Subject: [PATCH 4/9] Derive the update toast label from the live sidebar thread cache The busy/idle action label now reads the same push-updated sidebar-navigation cache that renders the sidebar's running spinners, instead of polling /system/agents/activity every 15s: the label flips instantly and always agrees with what the sidebar shows. The bootstrap excludes side-chats and archived threads, so an idle-looking label maps to mode "when-idle", whose server-side at-rest gate counts every thread before skipping the quiet period. The activity endpoint remains for the desktop main-process deferred-relaunch poller, which has no SPA cache. Co-Authored-By: Claude Fable 5 --- apps/app/src/hooks/queries/query-keys.ts | 8 ---- .../hooks/queries/sidebar-navigation-query.ts | 38 +++++++++++++++++++ apps/app/src/hooks/queries/system-queries.ts | 19 ---------- apps/app/src/hooks/useUpdateAvailableToast.ts | 24 +++++------- apps/app/src/lib/api.ts | 9 ----- 5 files changed, 47 insertions(+), 51 deletions(-) diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 9f8527d4e8..b5cabf7ab8 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -58,7 +58,6 @@ export const SYSTEM_PROVIDERS_QUERY_KEY = "systemProviders"; export const SYSTEM_CONFIG_QUERY_KEY = "systemConfig"; export const SYSTEM_EXECUTION_OPTIONS_QUERY_KEY = "systemExecutionOptions"; export const SYSTEM_VERSION_QUERY_KEY = "systemVersion"; -export const SYSTEM_AGENT_ACTIVITY_QUERY_KEY = "systemAgentActivity"; export const HOST_PROVIDER_CLI_STATUS_QUERY_KEY = "hostProviderCliStatus"; export const SYSTEM_USAGE_LIMITS_QUERY_KEY = "systemUsageLimits"; export const HOST_PATH_EXISTENCE_QUERY_KEY = "hostPathExistence"; @@ -423,9 +422,6 @@ export type SystemProvidersQueryKey = readonly [ ]; export type SystemConfigQueryKey = readonly [typeof SYSTEM_CONFIG_QUERY_KEY]; export type SystemVersionQueryKey = readonly [typeof SYSTEM_VERSION_QUERY_KEY]; -export type SystemAgentActivityQueryKey = readonly [ - typeof SYSTEM_AGENT_ACTIVITY_QUERY_KEY, -]; export type HostProviderCliStatusQueryKey = readonly [ typeof HOST_PROVIDER_CLI_STATUS_QUERY_KEY, string | null, @@ -1021,10 +1017,6 @@ export function systemVersionQueryKey(): SystemVersionQueryKey { return [SYSTEM_VERSION_QUERY_KEY]; } -export function systemAgentActivityQueryKey(): SystemAgentActivityQueryKey { - return [SYSTEM_AGENT_ACTIVITY_QUERY_KEY]; -} - export function hostProviderCliStatusQueryKey( hostId: string | null, ): HostProviderCliStatusQueryKey { diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.ts b/apps/app/src/hooks/queries/sidebar-navigation-query.ts index 7e331fbf65..bc2feb3355 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.ts +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.ts @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; +import type { ThreadListEntry } from "@bb/domain"; import type { SidebarBootstrapResponse } from "@bb/server-contract"; import { apiClient } from "@/lib/api-server"; import { request, requestOptions } from "@/lib/api"; @@ -48,6 +49,43 @@ export function useSidebarNavigation(options?: QueryOptions) { }); } +function isWorkingThread(thread: ThreadListEntry): boolean { + return ( + thread.status === "starting" || + thread.status === "active" || + thread.status === "stopping" + ); +} + +/** + * Whether any agent is working, derived from the same push-updated + * sidebar-navigation cache that renders the sidebar's running spinners — so + * surfaces like the update toast always agree with what the sidebar shows, + * with no polling. Returns undefined until the cache is populated. + * + * This is a view, not global truth: the bootstrap excludes side-chats and + * archived threads, so hidden work can be missed. Callers must map an "idle" + * reading to behavior the server re-checks authoritatively (e.g. mode + * "when-idle", whose at-rest gate counts every thread) rather than to + * anything destructive. + */ +export function useAnyAgentWorking(options?: QueryOptions): boolean | undefined { + const enabled = options?.enabled ?? true; + const { data } = useQuery({ + queryKey: sidebarNavigationQueryKey(), + queryFn: ({ signal }) => fetchSidebarNavigation(signal), + ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, + enabled, + }); + if (!data) { + return undefined; + } + return ( + data.personalProject.threads.some(isWorkingThread) || + data.projects.some((project) => project.threads.some(isWorkingThread)) + ); +} + /** * Read the active project's display name from the shared sidebar-navigation * cache. The sidebar owns the realtime subscriptions and initial load; this only diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 38ede3843a..36c3265793 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,7 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toRecord } from "@bb/core-ui"; import type { - SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemSelfUpdateMode, @@ -15,7 +14,6 @@ import { applySelfUpdateStateToVersionCache } from "@/hooks/cache-owners/system- import { useSystemRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { hostProviderCliStatusQueryKey, - systemAgentActivityQueryKey, systemConfigQueryKey, systemExecutionOptionsQueryKey, systemUsageLimitsQueryKey, @@ -118,23 +116,6 @@ export function useSystemVersion(options?: QueryOptions) { }); } -/** Poll cadence for the update toast's busy/idle action label. */ -const AGENT_ACTIVITY_REFETCH_MS = 15_000; - -/** - * Live agent load, used to pick "Update now" vs "Update when agents finish" - * on the update toast. Only enabled while that choice is on screen. - */ -export function useSystemAgentActivity(options?: QueryOptions) { - return useQuery({ - queryKey: systemAgentActivityQueryKey(), - queryFn: ({ signal }) => api.getSystemAgentActivity(signal), - enabled: options?.enabled ?? true, - refetchInterval: AGENT_ACTIVITY_REFETCH_MS, - staleTime: 5_000, - }); -} - function useApplySelfUpdateState() { const queryClient = useQueryClient(); return (selfUpdate: SystemSelfUpdateState): void => { diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index cf9868ff8b..5f458f6113 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -6,9 +6,9 @@ import { getBbDesktopInfo } from "@/lib/bb-desktop"; import { useCancelSelfUpdate, useScheduleSelfUpdate, - useSystemAgentActivity, useSystemVersion, } from "./queries/system-queries"; +import { useAnyAgentWorking } from "./queries/sidebar-navigation-query"; const DISMISSED_STORAGE_KEY_PREFIX = "bb:update-toast:dismissed:"; @@ -124,9 +124,10 @@ export function useUpdateAvailableToast(): void { const { data } = useSystemVersion(); const scheduleSelfUpdate = useScheduleSelfUpdate(); const cancelSelfUpdate = useCancelSelfUpdate(); - // Live agent load decides the action label: "Update now" when nothing is - // running, "Update when agents finish" otherwise. Only polled while the - // choice is actually on screen. + // The action label follows the sidebar's live running spinners (same + // push-updated cache), so the toast never contradicts what the user sees. + // "Update now" when nothing is working, "Update when agents finish" + // otherwise; unknown reads as busy since deferring is the safe default. const activityEnabled = getBbDesktopInfo() === null && data !== undefined && @@ -134,12 +135,8 @@ export function useUpdateAvailableToast(): void { data.updateAvailable && data.selfUpdate.capable && data.selfUpdate.scheduled === null; - const { data: agentActivity } = useSystemAgentActivity({ - enabled: activityEnabled, - }); - // Unknown activity reads as busy: the deferred label is the safe default. const agentsBusy = - agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; + useAnyAgentWorking({ enabled: activityEnabled }) ?? true; const shownForVersionRef = useRef(null); /** id of the scheduled-update toast currently on screen (version + phase). */ const scheduledToastKeyRef = useRef(null); @@ -337,18 +334,15 @@ export function useUpdateAvailableToast(): void { export function useDesktopUpdateAvailableToast(): void { const [desktopApi, setDesktopApi] = useState(null); const [desktopInfo, setDesktopInfo] = useState(null); - // The SPA in the desktop shell talks to the shell-owned server, so the same - // activity endpoint decides "Relaunch now" vs the deferred option. + // The SPA in the desktop shell renders the shell-owned server's sidebar, so + // the same live cache decides "Relaunch now" vs the deferred option. const activityEnabled = desktopInfo !== null && desktopInfo.updateDownloaded && (desktopInfo.deferredInstall ?? null) === null && desktopInfo.canDeferInstall === true; - const { data: agentActivity } = useSystemAgentActivity({ - enabled: activityEnabled, - }); const agentsBusy = - agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; + useAnyAgentWorking({ enabled: activityEnabled }) ?? true; const shownForVersionRef = useRef(null); useEffect(() => { diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index fec71aca83..e2033f4279 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -45,7 +45,6 @@ import type { SendQueuedMessageResponse, SetQueuedMessageGroupBoundaryRequest, SendMessageRequest, - SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, @@ -1748,14 +1747,6 @@ export async function getSystemVersion( ); } -export async function getSystemAgentActivity( - signal?: AbortSignal, -): Promise { - return request( - apiClient.system.agents.activity.$get({}, requestOptions(signal)), - ); -} - export async function scheduleSelfUpdate( mode: SystemSelfUpdateMode, ): Promise { From 75c2e1114bfd432091541f1ea1637ca2ac71b49e Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 14:42:03 -0700 Subject: [PATCH 5/9] Poll agent activity at 1s for the update toast label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the sidebar-cache derivation in favor of polling /system/agents/activity every second while the update choice is on screen. The endpoint is one indexed count on a small table (~1ms over loopback) and the poll is gated to when an update is available, capable, and not yet scheduled or dismissed — so the cost is negligible. Unlike the sidebar bootstrap (which excludes side-chats and archived threads) the endpoint counts every thread, so the busy/idle label is global truth rather than a filtered view, while still flipping within a second. Co-Authored-By: Claude Fable 5 --- apps/app/src/hooks/queries/query-keys.ts | 8 ++++ .../hooks/queries/sidebar-navigation-query.ts | 38 ------------------- apps/app/src/hooks/queries/system-queries.ts | 25 ++++++++++++ apps/app/src/hooks/useUpdateAvailableToast.ts | 24 +++++++----- apps/app/src/lib/api.ts | 9 +++++ 5 files changed, 57 insertions(+), 47 deletions(-) diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index b5cabf7ab8..9f8527d4e8 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -58,6 +58,7 @@ export const SYSTEM_PROVIDERS_QUERY_KEY = "systemProviders"; export const SYSTEM_CONFIG_QUERY_KEY = "systemConfig"; export const SYSTEM_EXECUTION_OPTIONS_QUERY_KEY = "systemExecutionOptions"; export const SYSTEM_VERSION_QUERY_KEY = "systemVersion"; +export const SYSTEM_AGENT_ACTIVITY_QUERY_KEY = "systemAgentActivity"; export const HOST_PROVIDER_CLI_STATUS_QUERY_KEY = "hostProviderCliStatus"; export const SYSTEM_USAGE_LIMITS_QUERY_KEY = "systemUsageLimits"; export const HOST_PATH_EXISTENCE_QUERY_KEY = "hostPathExistence"; @@ -422,6 +423,9 @@ export type SystemProvidersQueryKey = readonly [ ]; export type SystemConfigQueryKey = readonly [typeof SYSTEM_CONFIG_QUERY_KEY]; export type SystemVersionQueryKey = readonly [typeof SYSTEM_VERSION_QUERY_KEY]; +export type SystemAgentActivityQueryKey = readonly [ + typeof SYSTEM_AGENT_ACTIVITY_QUERY_KEY, +]; export type HostProviderCliStatusQueryKey = readonly [ typeof HOST_PROVIDER_CLI_STATUS_QUERY_KEY, string | null, @@ -1017,6 +1021,10 @@ export function systemVersionQueryKey(): SystemVersionQueryKey { return [SYSTEM_VERSION_QUERY_KEY]; } +export function systemAgentActivityQueryKey(): SystemAgentActivityQueryKey { + return [SYSTEM_AGENT_ACTIVITY_QUERY_KEY]; +} + export function hostProviderCliStatusQueryKey( hostId: string | null, ): HostProviderCliStatusQueryKey { diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.ts b/apps/app/src/hooks/queries/sidebar-navigation-query.ts index bc2feb3355..7e331fbf65 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.ts +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.ts @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { PERSONAL_PROJECT_ID } from "@bb/domain"; -import type { ThreadListEntry } from "@bb/domain"; import type { SidebarBootstrapResponse } from "@bb/server-contract"; import { apiClient } from "@/lib/api-server"; import { request, requestOptions } from "@/lib/api"; @@ -49,43 +48,6 @@ export function useSidebarNavigation(options?: QueryOptions) { }); } -function isWorkingThread(thread: ThreadListEntry): boolean { - return ( - thread.status === "starting" || - thread.status === "active" || - thread.status === "stopping" - ); -} - -/** - * Whether any agent is working, derived from the same push-updated - * sidebar-navigation cache that renders the sidebar's running spinners — so - * surfaces like the update toast always agree with what the sidebar shows, - * with no polling. Returns undefined until the cache is populated. - * - * This is a view, not global truth: the bootstrap excludes side-chats and - * archived threads, so hidden work can be missed. Callers must map an "idle" - * reading to behavior the server re-checks authoritatively (e.g. mode - * "when-idle", whose at-rest gate counts every thread) rather than to - * anything destructive. - */ -export function useAnyAgentWorking(options?: QueryOptions): boolean | undefined { - const enabled = options?.enabled ?? true; - const { data } = useQuery({ - queryKey: sidebarNavigationQueryKey(), - queryFn: ({ signal }) => fetchSidebarNavigation(signal), - ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, - enabled, - }); - if (!data) { - return undefined; - } - return ( - data.personalProject.threads.some(isWorkingThread) || - data.projects.some((project) => project.threads.some(isWorkingThread)) - ); -} - /** * Read the active project's display name from the shared sidebar-navigation * cache. The sidebar owns the realtime subscriptions and initial load; this only diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 36c3265793..45d99655a7 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toRecord } from "@bb/core-ui"; import type { + SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemSelfUpdateMode, @@ -14,6 +15,7 @@ import { applySelfUpdateStateToVersionCache } from "@/hooks/cache-owners/system- import { useSystemRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { hostProviderCliStatusQueryKey, + systemAgentActivityQueryKey, systemConfigQueryKey, systemExecutionOptionsQueryKey, systemUsageLimitsQueryKey, @@ -116,6 +118,29 @@ export function useSystemVersion(options?: QueryOptions) { }); } +/** + * Poll cadence for the update toast's busy/idle action label. 1s keeps the + * label effectively live; the endpoint is a single indexed count on a small + * table, and the poll only runs while the update choice is on screen (an + * update is available, capable, and not yet scheduled or dismissed). + */ +const AGENT_ACTIVITY_REFETCH_MS = 1_000; + +/** + * Live agent load, used to pick "Update now" vs "Update when agents finish" + * on the update toast. Unlike the sidebar's thread cache this counts every + * thread — side-chats and archived included — so the label is global truth. + */ +export function useSystemAgentActivity(options?: QueryOptions) { + return useQuery({ + queryKey: systemAgentActivityQueryKey(), + queryFn: ({ signal }) => api.getSystemAgentActivity(signal), + enabled: options?.enabled ?? true, + refetchInterval: AGENT_ACTIVITY_REFETCH_MS, + staleTime: 0, + }); +} + function useApplySelfUpdateState() { const queryClient = useQueryClient(); return (selfUpdate: SystemSelfUpdateState): void => { diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index 5f458f6113..cf9868ff8b 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -6,9 +6,9 @@ import { getBbDesktopInfo } from "@/lib/bb-desktop"; import { useCancelSelfUpdate, useScheduleSelfUpdate, + useSystemAgentActivity, useSystemVersion, } from "./queries/system-queries"; -import { useAnyAgentWorking } from "./queries/sidebar-navigation-query"; const DISMISSED_STORAGE_KEY_PREFIX = "bb:update-toast:dismissed:"; @@ -124,10 +124,9 @@ export function useUpdateAvailableToast(): void { const { data } = useSystemVersion(); const scheduleSelfUpdate = useScheduleSelfUpdate(); const cancelSelfUpdate = useCancelSelfUpdate(); - // The action label follows the sidebar's live running spinners (same - // push-updated cache), so the toast never contradicts what the user sees. - // "Update now" when nothing is working, "Update when agents finish" - // otherwise; unknown reads as busy since deferring is the safe default. + // Live agent load decides the action label: "Update now" when nothing is + // running, "Update when agents finish" otherwise. Only polled while the + // choice is actually on screen. const activityEnabled = getBbDesktopInfo() === null && data !== undefined && @@ -135,8 +134,12 @@ export function useUpdateAvailableToast(): void { data.updateAvailable && data.selfUpdate.capable && data.selfUpdate.scheduled === null; + const { data: agentActivity } = useSystemAgentActivity({ + enabled: activityEnabled, + }); + // Unknown activity reads as busy: the deferred label is the safe default. const agentsBusy = - useAnyAgentWorking({ enabled: activityEnabled }) ?? true; + agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; const shownForVersionRef = useRef(null); /** id of the scheduled-update toast currently on screen (version + phase). */ const scheduledToastKeyRef = useRef(null); @@ -334,15 +337,18 @@ export function useUpdateAvailableToast(): void { export function useDesktopUpdateAvailableToast(): void { const [desktopApi, setDesktopApi] = useState(null); const [desktopInfo, setDesktopInfo] = useState(null); - // The SPA in the desktop shell renders the shell-owned server's sidebar, so - // the same live cache decides "Relaunch now" vs the deferred option. + // The SPA in the desktop shell talks to the shell-owned server, so the same + // activity endpoint decides "Relaunch now" vs the deferred option. const activityEnabled = desktopInfo !== null && desktopInfo.updateDownloaded && (desktopInfo.deferredInstall ?? null) === null && desktopInfo.canDeferInstall === true; + const { data: agentActivity } = useSystemAgentActivity({ + enabled: activityEnabled, + }); const agentsBusy = - useAnyAgentWorking({ enabled: activityEnabled }) ?? true; + agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; const shownForVersionRef = useRef(null); useEffect(() => { diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index e2033f4279..fec71aca83 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -45,6 +45,7 @@ import type { SendQueuedMessageResponse, SetQueuedMessageGroupBoundaryRequest, SendMessageRequest, + SystemAgentActivityResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemProviderInfo, @@ -1747,6 +1748,14 @@ export async function getSystemVersion( ); } +export async function getSystemAgentActivity( + signal?: AbortSignal, +): Promise { + return request( + apiClient.system.agents.activity.$get({}, requestOptions(signal)), + ); +} + export async function scheduleSelfUpdate( mode: SystemSelfUpdateMode, ): Promise { From 55dc94f9ff7eb5c594f9bce7ca5abc54de6be787 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 15:03:49 -0700 Subject: [PATCH 6/9] Apply simplification review findings to the self-update feature From an agent review of the PR for simplification opportunities: - Flatten the launcher supervision loop: the inlined ~85-line swap branch becomes applySelfUpdateSwap + restartFromStack helpers, and the loop's duplicated per-process restart blocks collapse into one tail. - Drop requestedAt from the client-facing contracts (nothing read it); it survives only in the on-disk sentinel as debugging provenance. The desktop deferredInstall object collapses to a boolean. - Remove the illusory protocol "version" constant: the server parses the env var as a boolean, so the value never carried version information. - Share the 45s quiet period as one constant in @bb/config so the server watcher and desktop controller cannot drift. - Delete the dead parseSelfUpdateSentinel export, a redundant regex in resolveNpmInvocation, and the preload's duplicate invoke helper. - Extract useAgentsBusy so the unknown-reads-as-busy default cannot drift between the web and desktop toasts. No behavior changes. Co-Authored-By: Claude Fable 5 --- apps/app/src/hooks/queries/system-queries.ts | 9 + apps/app/src/hooks/useUpdateAvailableToast.ts | 20 +- apps/desktop/src/desktop-deferred-install.ts | 31 +-- apps/desktop/src/main.ts | 2 +- apps/desktop/src/preload.ts | 10 +- .../test/desktop-deferred-install.test.ts | 16 +- .../server/src/services/system/self-update.ts | 16 +- apps/server/test/system/self-update.test.ts | 2 - packages/bb-app/src/launcher.ts | 222 ++++++++++-------- packages/config/src/self-update.ts | 24 +- packages/desktop-contract/src/info.ts | 12 +- packages/server-contract/src/api/system.ts | 1 - 12 files changed, 178 insertions(+), 187 deletions(-) diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 45d99655a7..676695e8c0 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -141,6 +141,15 @@ export function useSystemAgentActivity(options?: QueryOptions) { }); } +/** + * Busy/idle for the update toasts, with the shared safe default: unknown + * activity reads as busy, since deferring is always the harmless choice. + */ +export function useAgentsBusy(options?: QueryOptions): boolean { + const { data } = useSystemAgentActivity(options); + return data === undefined ? true : data.busyThreadCount > 0; +} + function useApplySelfUpdateState() { const queryClient = useQueryClient(); return (selfUpdate: SystemSelfUpdateState): void => { diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index cf9868ff8b..b4f9e9d845 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -4,9 +4,9 @@ import type { BbDesktopApi, BbDesktopInfo } from "@bb/desktop-contract"; import type { SystemSelfUpdateScheduled } from "@bb/server-contract"; import { getBbDesktopInfo } from "@/lib/bb-desktop"; import { + useAgentsBusy, useCancelSelfUpdate, useScheduleSelfUpdate, - useSystemAgentActivity, useSystemVersion, } from "./queries/system-queries"; @@ -134,12 +134,7 @@ export function useUpdateAvailableToast(): void { data.updateAvailable && data.selfUpdate.capable && data.selfUpdate.scheduled === null; - const { data: agentActivity } = useSystemAgentActivity({ - enabled: activityEnabled, - }); - // Unknown activity reads as busy: the deferred label is the safe default. - const agentsBusy = - agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; + const agentsBusy = useAgentsBusy({ enabled: activityEnabled }); const shownForVersionRef = useRef(null); /** id of the scheduled-update toast currently on screen (version + phase). */ const scheduledToastKeyRef = useRef(null); @@ -342,13 +337,9 @@ export function useDesktopUpdateAvailableToast(): void { const activityEnabled = desktopInfo !== null && desktopInfo.updateDownloaded && - (desktopInfo.deferredInstall ?? null) === null && + desktopInfo.deferredInstall !== true && desktopInfo.canDeferInstall === true; - const { data: agentActivity } = useSystemAgentActivity({ - enabled: activityEnabled, - }); - const agentsBusy = - agentActivity === undefined ? true : agentActivity.busyThreadCount > 0; + const agentsBusy = useAgentsBusy({ enabled: activityEnabled }); const shownForVersionRef = useRef(null); useEffect(() => { @@ -395,8 +386,7 @@ export function useDesktopUpdateAvailableToast(): void { return; } - const deferredInstall = desktopInfo.deferredInstall ?? null; - if (deferredInstall !== null) { + if (desktopInfo.deferredInstall === true) { const deferredKey = `${latestVersion}:deferred`; if (shownForVersionRef.current === deferredKey) { return; diff --git a/apps/desktop/src/desktop-deferred-install.ts b/apps/desktop/src/desktop-deferred-install.ts index 18e4ea25e3..ebeb9ebed2 100644 --- a/apps/desktop/src/desktop-deferred-install.ts +++ b/apps/desktop/src/desktop-deferred-install.ts @@ -1,14 +1,8 @@ import { z } from "zod"; -import type { BbDesktopDeferredInstall } from "@bb/desktop-contract"; +import { BB_UPDATE_QUIET_PERIOD_MS } from "@bb/config/self-update"; import type { DesktopAutoUpdateLogger } from "./desktop-auto-update.js"; const DEFERRED_INSTALL_POLL_INTERVAL_MS = 15_000; -/** - * How long the owned runtime must report zero busy agents before the shell - * relaunches. Mirrors the server's own update-when-idle quiet period so a - * queued follow-up or automation handoff doesn't get interrupted. - */ -const DEFERRED_INSTALL_QUIET_PERIOD_MS = 45_000; const ACTIVITY_FETCH_TIMEOUT_MS = 5_000; const agentActivityResponseSchema = z @@ -45,7 +39,8 @@ export interface DeferredInstallController { /** True when a deferred install can be offered (owned runtime present). */ canDefer(): boolean; cancel(): void; - getState(): BbDesktopDeferredInstall | null; + /** True while a deferred install is pending. */ + isPending(): boolean; /** Returns true when the deferral was accepted and polling started. */ request(): boolean; stop(): void; @@ -58,9 +53,9 @@ export function createDeferredInstallController( const fetchImpl = args.fetchImpl ?? fetch; const now = args.now ?? (() => Date.now()); const pollIntervalMs = args.pollIntervalMs ?? DEFERRED_INSTALL_POLL_INTERVAL_MS; - const quietPeriodMs = args.quietPeriodMs ?? DEFERRED_INSTALL_QUIET_PERIOD_MS; + const quietPeriodMs = args.quietPeriodMs ?? BB_UPDATE_QUIET_PERIOD_MS; - let state: BbDesktopDeferredInstall | null = null; + let pending = false; let idleSince: number | null = null; let pollHandle: ReturnType | null = null; let installing = false; @@ -78,8 +73,8 @@ export function createDeferredInstallController( pollHandle = null; } idleSince = null; - if (state !== null) { - state = null; + if (pending) { + pending = false; notify(); } } @@ -106,7 +101,7 @@ export function createDeferredInstallController( } async function tick(): Promise { - if (state === null || installing) { + if (!pending || installing) { return; } if (!args.isUpdateDownloaded()) { @@ -168,22 +163,22 @@ export function createDeferredInstallController( return args.getProbe() !== null; }, cancel(): void { - if (state !== null) { + if (pending) { args.logger.info("Deferred desktop install cancelled by user."); } clear(); }, - getState(): BbDesktopDeferredInstall | null { - return state; + isPending(): boolean { + return pending; }, request(): boolean { if (!args.isUpdateDownloaded() || args.getProbe() === null) { return false; } - if (state !== null) { + if (pending) { return true; } - state = { requestedAt: new Date(now()).toISOString() }; + pending = true; idleSince = null; pollHandle = setInterval(() => { void tick(); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 39160897f4..0cf1708caf 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -411,7 +411,7 @@ function getCurrentDesktopInfo(): BbDesktopInfo | null { return { ...merged, canDeferInstall: deferredInstallController?.canDefer() ?? false, - deferredInstall: deferredInstallController?.getState() ?? null, + deferredInstall: deferredInstallController?.isPending() ?? false, }; } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e6728ae80f..314f255df6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -112,14 +112,6 @@ async function invokeDesktopInfo(channel: string): Promise { } } -async function invokeInstallUpdate(): Promise { - try { - await ipcRenderer.invoke(BB_DESKTOP_INSTALL_UPDATE_CHANNEL); - } catch { - return; - } -} - async function invokeVoidChannel(channel: string): Promise { try { await ipcRenderer.invoke(channel); @@ -270,7 +262,7 @@ const bbDesktopApi: BbDesktopApi = { return invokeDesktopInfo(BB_DESKTOP_GET_INFO_CHANNEL); }, installUpdate() { - return invokeInstallUpdate(); + return invokeVoidChannel(BB_DESKTOP_INSTALL_UPDATE_CHANNEL); }, installUpdateWhenIdle() { return invokeVoidChannel(BB_DESKTOP_INSTALL_UPDATE_WHEN_IDLE_CHANNEL); diff --git a/apps/desktop/test/desktop-deferred-install.test.ts b/apps/desktop/test/desktop-deferred-install.test.ts index 3b124fa5e8..e810ded824 100644 --- a/apps/desktop/test/desktop-deferred-install.test.ts +++ b/apps/desktop/test/desktop-deferred-install.test.ts @@ -55,7 +55,7 @@ describe("desktop deferred install controller", () => { it("refuses to defer without a downloaded update or an owned runtime", () => { const noUpdate = createHarness({ updateDownloaded: () => false }); expect(noUpdate.controller.request()).toBe(false); - expect(noUpdate.controller.getState()).toBeNull(); + expect(noUpdate.controller.isPending()).toBe(false); const noRuntime = createHarness({ hasProbe: false }); expect(noRuntime.controller.canDefer()).toBe(false); @@ -65,9 +65,7 @@ describe("desktop deferred install controller", () => { it("relaunches after agents stay idle for the quiet period", async () => { const harness = createHarness(); expect(harness.controller.request()).toBe(true); - expect(harness.controller.getState()).toEqual({ - requestedAt: expect.any(String), - }); + expect(harness.controller.isPending()).toBe(true); await vi.waitFor(() => { expect(harness.installUpdate).toHaveBeenCalledTimes(1); @@ -96,15 +94,15 @@ describe("desktop deferred install controller", () => { it("cancel clears the deferral and stops polling", async () => { let busy = 1; const harness = createHarness({ busyCounts: () => busy }); - const changes: Array> = []; + const changes: boolean[] = []; harness.controller.subscribe(() => { - changes.push(harness.controller.getState()); + changes.push(harness.controller.isPending()); }); harness.controller.request(); harness.controller.cancel(); - expect(harness.controller.getState()).toBeNull(); - expect(changes.at(-1)).toBeNull(); + expect(harness.controller.isPending()).toBe(false); + expect(changes.at(-1)).toBe(false); busy = 0; await new Promise((resolve) => setTimeout(resolve, 80)); @@ -121,7 +119,7 @@ describe("desktop deferred install controller", () => { downloaded = false; await vi.waitFor(() => { - expect(harness.controller.getState()).toBeNull(); + expect(harness.controller.isPending()).toBe(false); }); expect(harness.installUpdate).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/services/system/self-update.ts b/apps/server/src/services/system/self-update.ts index 25ee173390..d62505c5dc 100644 --- a/apps/server/src/services/system/self-update.ts +++ b/apps/server/src/services/system/self-update.ts @@ -6,6 +6,7 @@ import semver from "semver"; import { z } from "zod"; import { BB_SELF_UPDATE_EXIT_CODE, + BB_UPDATE_QUIET_PERIOD_MS, formatSelfUpdateSentinelPath, formatSelfUpdateStagingDir, formatSelfUpdateStagingRoot, @@ -25,12 +26,8 @@ import { countBusyThreads, isServerAtRest } from "./agent-activity.js"; import type { AppVersionService } from "./app-version.js"; const DEFAULT_POLL_INTERVAL_MS = 15_000; -/** - * How long the server must see zero busy agents before it exits for the swap. - * Covers the gaps where a thread is momentarily idle between a finished turn - * and a queued follow-up or automation-triggered turn starting. - */ -const DEFAULT_QUIET_PERIOD_MS = 45_000; +// Shared with the desktop shell's deferred relaunch; see the constant's doc. +const DEFAULT_QUIET_PERIOD_MS = BB_UPDATE_QUIET_PERIOD_MS; const STAGING_INSTALL_TIMEOUT_MS = 10 * 60 * 1000; const INSTALL_LOG_TAIL_CHARS = 2_000; @@ -82,7 +79,6 @@ export function resolveNpmInvocation(env: NodeJS.ProcessEnv): { const npmExecPath = env.npm_execpath; if ( npmExecPath !== undefined && - /\.(c|m)?js$/.test(npmExecPath) && /(^|\/)npm(-cli)?\.(c|m)?js$/.test(npmExecPath) ) { return { command: process.execPath, argsPrefix: [npmExecPath] }; @@ -365,7 +361,7 @@ export function createSelfUpdateService( // Re-read the mode: an "Update now" click may have escalated the // schedule while npm was installing. const mode = scheduled?.mode ?? "when-idle"; - scheduled = { targetVersion, requestedAt, phase: "waiting", mode }; + scheduled = { targetVersion, phase: "waiting", mode }; if (mode === "now") { logger.info( { targetVersion }, @@ -431,8 +427,9 @@ export function createSelfUpdateService( } lastError = null; + // Only persisted in the sentinel, as provenance for data-dir debugging. const requestedAt = new Date(now()).toISOString(); - scheduled = { targetVersion, requestedAt, phase: "staging", mode }; + scheduled = { targetVersion, phase: "staging", mode }; const generation = scheduleGeneration; void stageAndArm(targetVersion, requestedAt, generation); return getState(); @@ -494,7 +491,6 @@ export function createSelfUpdateService( // when-idle rather than surprise-restarting agents after a crash. scheduled = { targetVersion: sentinel.targetVersion, - requestedAt: sentinel.requestedAt, phase: "waiting", mode: "when-idle", }; diff --git a/apps/server/test/system/self-update.test.ts b/apps/server/test/system/self-update.test.ts index e810094c30..589d3e4b96 100644 --- a/apps/server/test/system/self-update.test.ts +++ b/apps/server/test/system/self-update.test.ts @@ -205,7 +205,6 @@ describe("self-update service", () => { const state = await harness.service.schedule("when-idle"); expect(state.scheduled).toEqual({ targetVersion: TARGET_VERSION, - requestedAt: expect.any(String), phase: "staging", mode: "when-idle", }); @@ -382,7 +381,6 @@ describe("self-update service", () => { await harness.service.resume(); expect(harness.service.getState().scheduled).toEqual({ targetVersion: TARGET_VERSION, - requestedAt: expect.any(String), phase: "waiting", mode: "when-idle", }); diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index d24b72ca0b..01379fd9ab 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -24,7 +24,6 @@ import { import { BB_SELF_UPDATE_EXIT_CODE, BB_SELF_UPDATE_PROTOCOL_ENV_NAME, - BB_SELF_UPDATE_PROTOCOL_VERSION, formatSelfUpdateSentinelPath, selfUpdateSentinelSchema, type SelfUpdateSentinel, @@ -2194,7 +2193,7 @@ function createServerEnv(args: CreateServerEnvArgs): NodeJS.ProcessEnv { NODE_ENV: "production", }; if (args.selfUpdateProtocol) { - env[BB_SELF_UPDATE_PROTOCOL_ENV_NAME] = BB_SELF_UPDATE_PROTOCOL_VERSION; + env[BB_SELF_UPDATE_PROTOCOL_ENV_NAME] = "1"; } else { delete env[BB_SELF_UPDATE_PROTOCOL_ENV_NAME]; } @@ -2698,14 +2697,110 @@ export async function terminateManagedFullStackProcesses( await Promise.all(terminationPromises); } +/** + * The context and start closures supervision currently launches from. A + * successful self-update swap replaces all three, so later crash restarts + * relaunch the updated version. + */ +interface SupervisedStack { + context: BbAppStartContext; + startDaemon: StartManagedProcess; + startServer: StartManagedProcess; +} + +/** Restart one child from the current stack. False means shutdown began. */ +async function restartFromStack( + args: SuperviseFullStackProcessesArgs, + stack: SupervisedStack, + processName: ManagedProcessName, +): Promise { + const restarted = await restartManagedProcess({ + context: stack.context, + delayMilliseconds: args.delayMilliseconds, + isShutdownRequested: args.isShutdownRequested, + processName, + start: processName === "server" ? stack.startServer : stack.startDaemon, + }); + return restarted !== null; +} + +type SelfUpdateSwapOutcome = + | "rolled-back" + | "shutdown" + | "swapped" + | "unusable"; + +/** + * Handle a server exit that requested a self-update: consume the sentinel and + * restart both children from the staged version, mutating the stack on + * success. A staged version that fails to start is torn down and the current + * version restarted ("rolled-back"); "unusable" means there was nothing valid + * to swap to and the caller should restart the server normally. + */ +async function applySelfUpdateSwap( + args: SuperviseFullStackProcessesArgs, + stack: SupervisedStack, +): Promise { + const swap = (await args.trySelfUpdateSwap?.()) ?? null; + if (args.isShutdownRequested()) { + return "shutdown"; + } + if (swap === null) { + return "unusable"; + } + + log( + yellow("!"), + `Server stopped for a self-update - switching to bb-app ${swap.context.appVersion}`, + ); + const daemonRun = args.processes.daemonRun; + if (daemonRun !== null) { + await daemonRun.terminate("SIGTERM"); + if (args.processes.daemonRun === daemonRun) { + args.processes.daemonRun = null; + } + } + + beginStep(`Starting bb-app ${swap.context.appVersion}`); + try { + await swap.startServer(); + await swap.startDaemon(); + stack.context = swap.context; + stack.startServer = swap.startServer; + stack.startDaemon = swap.startDaemon; + endStep(green("✓"), `Updated to bb-app ${swap.context.appVersion}`); + return "swapped"; + } catch { + endStep( + red("✗"), + `bb-app ${swap.context.appVersion} failed to start - restarting the current version`, + ); + // A half-started new stack may be running (e.g. server healthy but + // daemon failed); tear it down before restarting the old version. + await terminateManagedFullStackProcesses({ + processes: args.processes, + signal: "SIGTERM", + }); + args.processes.serverRun = null; + args.processes.daemonRun = null; + if (!(await restartFromStack(args, stack, "server"))) { + return "shutdown"; + } + if (!(await restartFromStack(args, stack, "daemon"))) { + return "shutdown"; + } + return "rolled-back"; + } +} + export async function superviseFullStackProcesses( args: SuperviseFullStackProcessesArgs, ): Promise { - // Mutable: a successful self-update swap replaces the start context and - // closures, so later crash restarts relaunch the updated version. - let context = args.context; - let startServer = args.startServer; - let startDaemon = args.startDaemon; + const stack: SupervisedStack = { + context: args.context, + startDaemon: args.startDaemon, + startServer: args.startServer, + }; while (!args.isShutdownRequested()) { const serverRun = args.processes.serverRun; @@ -2719,126 +2814,47 @@ export async function superviseFullStackProcesses( return "shutdown"; } + const exitedName = exitedProcess.processName; + if (exitedName === "server") { + if (args.processes.serverRun === serverRun) { + args.processes.serverRun = null; + } + } else if (args.processes.daemonRun === daemonRun) { + args.processes.daemonRun = null; + } + if ( - exitedProcess.processName === "server" && + exitedName === "server" && exitedProcess.result.code === BB_SELF_UPDATE_EXIT_CODE && args.trySelfUpdateSwap !== undefined ) { - if (args.processes.serverRun === serverRun) { - args.processes.serverRun = null; - } - const swap = await args.trySelfUpdateSwap(); - if (args.isShutdownRequested()) { + const outcome = await applySelfUpdateSwap(args, stack); + if (outcome === "shutdown") { return "shutdown"; } - if (swap !== null) { - log( - yellow("!"), - `Server stopped for a self-update - switching to bb-app ${swap.context.appVersion}`, - ); - await daemonRun.terminate("SIGTERM"); - if (args.processes.daemonRun === daemonRun) { - args.processes.daemonRun = null; - } - beginStep(`Starting bb-app ${swap.context.appVersion}`); - try { - await swap.startServer(); - await swap.startDaemon(); - context = swap.context; - startServer = swap.startServer; - startDaemon = swap.startDaemon; - endStep(green("✓"), `Updated to bb-app ${swap.context.appVersion}`); - continue; - } catch { - endStep( - red("✗"), - `bb-app ${swap.context.appVersion} failed to start - restarting the current version`, - ); - // A half-started new stack may be running (e.g. server healthy but - // daemon failed); tear it down before restarting the old version. - await terminateManagedFullStackProcesses({ - processes: args.processes, - signal: "SIGTERM", - }); - args.processes.serverRun = null; - args.processes.daemonRun = null; - const restartedServer = await restartManagedProcess({ - context, - delayMilliseconds: args.delayMilliseconds, - isShutdownRequested: args.isShutdownRequested, - processName: "server", - start: startServer, - }); - if (restartedServer === null) { - return "shutdown"; - } - const restartedDaemon = await restartManagedProcess({ - context, - delayMilliseconds: args.delayMilliseconds, - isShutdownRequested: args.isShutdownRequested, - processName: "daemon", - start: startDaemon, - }); - if (restartedDaemon === null) { - return "shutdown"; - } - continue; - } + if (outcome !== "unusable") { + continue; } log( yellow("!"), "Server exited for a self-update but no staged update was usable - restarting server", ); - // Fall through to the normal restart path below. } else { log( yellow("!"), - `${formatManagedProcessLabel(exitedProcess.processName)} exited with ${formatProcessExitResult( + `${formatManagedProcessLabel(exitedName)} exited with ${formatProcessExitResult( exitedProcess.result, - )} - restarting ${formatManagedProcessLabel(exitedProcess.processName)}`, + )} - restarting ${formatManagedProcessLabel(exitedName)}`, ); } - if (exitedProcess.processName === "server") { - if (args.processes.serverRun === serverRun) { - args.processes.serverRun = null; - } - await args.delayMilliseconds({ - ms: MANAGED_PROCESS_RESTART_RETRY_DELAY_MS, - }); - if (args.isShutdownRequested()) { - return "shutdown"; - } - const restartedServer = await restartManagedProcess({ - context, - delayMilliseconds: args.delayMilliseconds, - isShutdownRequested: args.isShutdownRequested, - processName: "server", - start: startServer, - }); - if (restartedServer === null) { - return "shutdown"; - } - continue; - } - - if (args.processes.daemonRun === daemonRun) { - args.processes.daemonRun = null; - } await args.delayMilliseconds({ ms: MANAGED_PROCESS_RESTART_RETRY_DELAY_MS, }); if (args.isShutdownRequested()) { return "shutdown"; } - const restartedDaemon = await restartManagedProcess({ - context, - delayMilliseconds: args.delayMilliseconds, - isShutdownRequested: args.isShutdownRequested, - processName: "daemon", - start: startDaemon, - }); - if (restartedDaemon === null) { + if (!(await restartFromStack(args, stack, exitedName))) { return "shutdown"; } } diff --git a/packages/config/src/self-update.ts b/packages/config/src/self-update.ts index 22845c4ab4..6d0c447090 100644 --- a/packages/config/src/self-update.ts +++ b/packages/config/src/self-update.ts @@ -16,13 +16,23 @@ import { z } from "zod"; export const BB_SELF_UPDATE_EXIT_CODE = 75; /** - * Env var the bb-app launcher sets on the server child to advertise that it - * understands the self-update protocol. Absent under `bb-server`, dev runs, - * and the desktop shell (which updates through electron-updater instead), so - * old launchers can never be asked to perform a swap they don't understand. + * Env var the bb-app launcher sets (truthy) on the server child to advertise + * that it understands the self-update protocol. Absent under `bb-server`, + * dev runs, and the desktop shell (which updates through electron-updater + * instead), so old launchers can never be asked to perform a swap they don't + * understand. The server parses it as a boolean, so the value carries no + * version information — a protocol change needs a new variable. */ export const BB_SELF_UPDATE_PROTOCOL_ENV_NAME = "BB_SELF_UPDATE_PROTOCOL"; -export const BB_SELF_UPDATE_PROTOCOL_VERSION = "1"; + +/** + * How long agents must stay continuously idle before a deferred update + * applies. Shared by the server's idle watcher and the desktop shell's + * deferred relaunch so the two policies cannot drift. Sized to comfortably + * exceed the 10s queued-message auto-send sweep, whose dispatch gaps make a + * mid-chain thread look momentarily idle. + */ +export const BB_UPDATE_QUIET_PERIOD_MS = 45_000; export const selfUpdateSentinelSchema = z.object({ /** bb-app version staged and ready to swap to. */ @@ -62,7 +72,3 @@ export function formatStagedPackageRoot( "bb-app", ); } - -export function parseSelfUpdateSentinel(value: unknown): SelfUpdateSentinel { - return selfUpdateSentinelSchema.parse(value); -} diff --git a/packages/desktop-contract/src/info.ts b/packages/desktop-contract/src/info.ts index eae90fdb90..50db6f38f0 100644 --- a/packages/desktop-contract/src/info.ts +++ b/packages/desktop-contract/src/info.ts @@ -4,14 +4,6 @@ import type { BbDesktopPopoutApi } from "./popout.js"; const isoUtcDateTimeSchema = z.iso.datetime(); -/** A pending "relaunch when agents finish" deferred update install. */ -export const bbDesktopDeferredInstallSchema = z.object({ - requestedAt: isoUtcDateTimeSchema, -}); -export type BbDesktopDeferredInstall = z.infer< - typeof bbDesktopDeferredInstallSchema ->; - export const bbDesktopInfoSchema = z.object({ lastCheckedAt: isoUtcDateTimeSchema.nullable(), latestVersion: z.string().min(1).nullable(), @@ -27,10 +19,10 @@ export const bbDesktopInfoSchema = z.object({ */ canDeferInstall: z.boolean().optional(), /** - * The pending deferred install, or null when none is scheduled. Absent on + * True while a "relaunch when agents finish" install is pending. Absent on * desktop shells that predate deferred installs. */ - deferredInstall: bbDesktopDeferredInstallSchema.nullable().optional(), + deferredInstall: z.boolean().optional(), }); export type BbDesktopInfo = z.infer; diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index d4b6321d02..c376c4bebe 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -128,7 +128,6 @@ export type SystemSelfUpdateScheduleRequest = z.infer< */ export const systemSelfUpdateScheduledSchema = z.object({ targetVersion: z.string(), - requestedAt: z.string(), phase: z.enum(["staging", "waiting"]), mode: systemSelfUpdateModeSchema, }); From 4c21abdb61ae702d030c9cdac9ee614c7481b0c4 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 15:18:18 -0700 Subject: [PATCH 7/9] Compact the update toasts: title-only copy, corner dismiss, "when idle" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent update toasts were verbose and, once both button slots held real actions, had no visible way to dismiss. AppToastContent gains an opt-in corner close button (routes through the toast's normal dismissal, so onDismiss persistence still applies). The update toasts drop their redundant description lines — the version moves into the title — and the deferred action reads "Update when idle" / "Relaunch when idle". X on a scheduled toast only hides it; the update itself continues. Co-Authored-By: Claude Fable 5 --- apps/app/src/components/ui/app-toast.tsx | 21 ++++++ apps/app/src/hooks/useUpdateAvailableToast.ts | 70 ++++++------------- 2 files changed, 42 insertions(+), 49 deletions(-) diff --git a/apps/app/src/components/ui/app-toast.tsx b/apps/app/src/components/ui/app-toast.tsx index b9ac105a59..b32257b90d 100644 --- a/apps/app/src/components/ui/app-toast.tsx +++ b/apps/app/src/components/ui/app-toast.tsx @@ -34,6 +34,9 @@ export interface AppToastOptions action?: Action; cancel?: Action; description?: ReactNode; + /** Render a corner close button. For persistent toasts whose button slots + * are taken by real actions, so plain dismissal stays one click. */ + showCloseButton?: boolean; } export interface AppToastContentProps { @@ -41,6 +44,7 @@ export interface AppToastContentProps { cancel?: Action; description?: ReactNode; id?: number | string; + showCloseButton?: boolean; title: ReactNode; tone: AppToastTone; } @@ -123,6 +127,7 @@ export function AppToastContent({ cancel, description, id, + showCloseButton, title, tone, }: AppToastContentProps) { @@ -133,6 +138,20 @@ export function AppToastContent({
+ {showCloseButton ? ( + + ) : null}
diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index b4f9e9d845..d8f81719f3 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -78,27 +78,21 @@ function clearDismissedForVersion(args: VersionDismissalArgs): void { } } -function appUpdateDescription(latestVersion: string): string { - return `${latestVersion} is available. Restart bb-app to update.`; +function availableToastTitle(latestVersion: string): string { + return `bb-app ${latestVersion} available`; } function scheduledUpdateToastId(targetVersion: string): string { return `bb-update-scheduled:${targetVersion}`; } -function scheduledUpdateDescription( - scheduled: SystemSelfUpdateScheduled, -): string { +function scheduledUpdateTitle(scheduled: SystemSelfUpdateScheduled): string { if (scheduled.mode === "now") { return `Updating to ${scheduled.targetVersion} now…`; } return scheduled.phase === "staging" ? `Preparing update to ${scheduled.targetVersion}…` - : `bb will update to ${scheduled.targetVersion} once no agents are running.`; -} - -function desktopReadyToastDescription(latestVersion: string): string { - return `bb desktop ${latestVersion} is ready to install.`; + : `Updating to ${scheduled.targetVersion} when idle`; } function desktopDeferredToastId(latestVersion: string): string { @@ -125,7 +119,7 @@ export function useUpdateAvailableToast(): void { const scheduleSelfUpdate = useScheduleSelfUpdate(); const cancelSelfUpdate = useCancelSelfUpdate(); // Live agent load decides the action label: "Update now" when nothing is - // running, "Update when agents finish" otherwise. Only polled while the + // running, "Update when idle" otherwise. Only polled while the // choice is actually on screen. const activityEnabled = getBbDesktopInfo() === null && @@ -176,10 +170,11 @@ export function useUpdateAvailableToast(): void { } scheduledToastKeyRef.current = toastKey; const toastId = scheduledUpdateToastId(scheduled.targetVersion); - appToast.message("bb-app update scheduled", { + appToast.message(scheduledUpdateTitle(scheduled), { id: toastId, - description: scheduledUpdateDescription(scheduled), duration: Infinity, + // X hides the toast; the update itself continues. + showCloseButton: true, cancel: { label: "Cancel update", onClick: () => { @@ -261,20 +256,17 @@ export function useUpdateAvailableToast(): void { storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, }); }; - appToast.message("bb-app update available", { + appToast.message(availableToastTitle(latestVersion), { id: availableToastId, - description: selfUpdate.capable - ? `${latestVersion} is available.` - : appUpdateDescription(latestVersion), duration: Infinity, + // The corner X is the dismissal; it routes through onDismiss below. + showCloseButton: true, ...(selfUpdate.capable ? agentsBusy ? { - // Both choices while agents work: defer (safe default) or an - // explicit immediate update that interrupts them. Dismissal - // falls back to swiping the toast away. + // Defer (safe default) or explicitly interrupt running agents. action: { - label: "Update when agents finish", + label: "Update when idle", onClick: () => { scheduleUpdate("when-idle"); }, @@ -296,29 +288,8 @@ export function useUpdateAvailableToast(): void { scheduleUpdate("when-idle"); }, }, - cancel: { - label: "Dismiss", - onClick: () => { - markDismissedForVersion({ - latestVersion, - storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, - }); - appToast.dismiss(availableToastId); - }, - }, } - : { - cancel: { - label: "Dismiss", - onClick: () => { - markDismissedForVersion({ - latestVersion, - storageKeyPrefix: DISMISSED_STORAGE_KEY_PREFIX, - }); - appToast.dismiss(availableToastId); - }, - }, - }), + : { description: "Restart bb-app to update." }), onDismiss: () => { markDismissedForVersion({ latestVersion, @@ -393,10 +364,11 @@ export function useDesktopUpdateAvailableToast(): void { } shownForVersionRef.current = deferredKey; appToast.dismiss(`bb-desktop-update-ready:${latestVersion}`); - appToast.message("Desktop update scheduled", { + appToast.message(`Relaunching to ${latestVersion} when idle`, { id: desktopDeferredToastId(latestVersion), - description: `bb desktop will relaunch to ${latestVersion} once no agents are running.`, duration: Infinity, + // X hides the toast; the deferred relaunch itself continues. + showCloseButton: true, cancel: { label: "Cancel update", onClick: () => { @@ -407,7 +379,7 @@ export function useDesktopUpdateAvailableToast(): void { return; } - // Only offer "when agents finish" when the shell owns a local runtime a + // Only offer "Relaunch when idle" when the shell owns a local runtime a // relaunch would interrupt, it's new enough to support deferral, and // agents are actually working; at rest a plain relaunch loses nothing. const canDefer = @@ -420,13 +392,13 @@ export function useDesktopUpdateAvailableToast(): void { } shownForVersionRef.current = readyKey; appToast.dismiss(desktopDeferredToastId(latestVersion)); - appToast.message("Desktop update ready", { + appToast.message(`bb desktop ${latestVersion} ready`, { id: `bb-desktop-update-ready:${latestVersion}`, - description: desktopReadyToastDescription(latestVersion), duration: Infinity, + showCloseButton: true, action: canDefer ? { - label: "Relaunch when agents finish", + label: "Relaunch when idle", onClick: () => { deferDesktopUpdate({ desktopApi, latestVersion }); }, From eedbda554b77891e9ac1eaf9bb6d48aa99eebdf3 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 15:22:51 -0700 Subject: [PATCH 8/9] Reload the page after a completed self-update The tab that watched the update was left running the pre-update frontend bundle. On seeing the post-swap version, reload the page and stash the version in session storage so the fresh bundle announces "bb-app updated" after the reload. Co-Authored-By: Claude Fable 5 --- apps/app/src/hooks/useUpdateAvailableToast.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index d8f81719f3..3b9bba3ee2 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -11,6 +11,8 @@ import { } from "./queries/system-queries"; const DISMISSED_STORAGE_KEY_PREFIX = "bb:update-toast:dismissed:"; +/** Set just before the post-update reload so the fresh page can announce it. */ +const UPDATED_SESSION_KEY = "bb:update-toast:completed"; interface VersionDismissalArgs { latestVersion: string; @@ -139,6 +141,22 @@ export function useUpdateAvailableToast(): void { const scheduleMutate = scheduleSelfUpdate.mutate; const cancelMutate = cancelSelfUpdate.mutate; + // Announce a completed update after the post-update reload, from the fresh + // bundle. Session storage survives the reload but not the tab. + useEffect(() => { + try { + const updatedVersion = sessionStorage.getItem(UPDATED_SESSION_KEY); + if (updatedVersion !== null) { + sessionStorage.removeItem(UPDATED_SESSION_KEY); + appToast.success("bb-app updated", { + description: `bb is now running ${updatedVersion}.`, + }); + } + } catch { + // Session storage may be unavailable; skip the announcement. + } + }, []); + useEffect(() => { if (!data) { return; @@ -192,9 +210,14 @@ export function useUpdateAvailableToast(): void { appToast.dismiss(scheduledUpdateToastId(watchedTargetVersion)); if (data.currentVersion === watchedTargetVersion) { watchedTargetVersionRef.current = null; - appToast.success("bb-app updated", { - description: `bb is now running ${data.currentVersion}.`, - }); + // The page is still running the pre-update frontend bundle; reload to + // pick up the new one. The session flag re-announces after reload. + try { + sessionStorage.setItem(UPDATED_SESSION_KEY, data.currentVersion); + } catch { + // Reload anyway; only the announcement is lost. + } + window.location.reload(); return; } if ( From 350c1ffdde1664f78ede4e352316fa63de6ecd9a Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Tue, 7 Jul 2026 15:40:26 -0700 Subject: [PATCH 9/9] Cut the update quiet period to 10s and fix the post-update blank page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quiet period: queued follow-ups are now checked directly on every watcher tick (server via countQueuedIdleThreads, desktop via a queuedThreadCount field on /system/agents/activity) instead of insured against with time, so the shared quiet period drops from 45s to 10s and polls from 15s to 5s. After the last agent finishes, a deferred update now applies in ~10-15s. Queued work also reads as busy for the toast label. Blank page: reloading into the updated app could race the restart — a failed module fetch never retries, stranding a blank page (reproduced in a real browser). The reload now waits until the server serves the app shell, and index.html gains a one-shot boot watchdog that reloads if the app has not mounted after 10s. Co-Authored-By: Claude Fable 5 --- apps/app/index.html | 24 ++++++++++++++++++ apps/app/src/hooks/queries/system-queries.ts | 6 ++++- apps/app/src/hooks/useUpdateAvailableToast.ts | 25 ++++++++++++++++++- apps/desktop/src/desktop-deferred-install.ts | 20 +++++++++------ apps/server/src/routes/system.ts | 10 ++++++-- .../src/services/system/agent-activity.ts | 14 ++++------- .../server/src/services/system/self-update.ts | 18 ++++++++----- .../server/test/system/agent-activity.test.ts | 1 + packages/config/src/self-update.ts | 10 +++++--- packages/server-contract/src/api/system.ts | 6 +++++ 10 files changed, 104 insertions(+), 30 deletions(-) diff --git a/apps/app/index.html b/apps/app/index.html index 20197c5f4f..61e7ec00cb 100644 --- a/apps/app/index.html +++ b/apps/app/index.html @@ -81,6 +81,30 @@
+ diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 676695e8c0..1275893237 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -144,10 +144,14 @@ export function useSystemAgentActivity(options?: QueryOptions) { /** * Busy/idle for the update toasts, with the shared safe default: unknown * activity reads as busy, since deferring is always the harmless choice. + * Queued follow-ups count as busy — an update would orphan them, so + * "Update now" would not actually be immediate. */ export function useAgentsBusy(options?: QueryOptions): boolean { const { data } = useSystemAgentActivity(options); - return data === undefined ? true : data.busyThreadCount > 0; + return data === undefined + ? true + : data.busyThreadCount > 0 || data.queuedThreadCount > 0; } function useApplySelfUpdateState() { diff --git a/apps/app/src/hooks/useUpdateAvailableToast.ts b/apps/app/src/hooks/useUpdateAvailableToast.ts index 3b9bba3ee2..052a51a937 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.ts +++ b/apps/app/src/hooks/useUpdateAvailableToast.ts @@ -84,6 +84,29 @@ function availableToastTitle(latestVersion: string): string { return `bb-app ${latestVersion} available`; } +/** + * Reload into the post-update frontend, but only once the just-restarted + * server actually serves the app shell — reloading mid-restart fails module + * fetches, which never retry and strand a blank page (index.html's boot + * watchdog is the second line of defense). + */ +async function reloadWhenShellServable(): Promise { + for (let attempt = 0; attempt < 15; attempt += 1) { + try { + const response = await fetch("/", { cache: "no-store" }); + if (response.ok) { + break; + } + } catch { + // Server still restarting; retry below. + } + await new Promise((resolve) => { + setTimeout(resolve, 1_000); + }); + } + window.location.reload(); +} + function scheduledUpdateToastId(targetVersion: string): string { return `bb-update-scheduled:${targetVersion}`; } @@ -217,7 +240,7 @@ export function useUpdateAvailableToast(): void { } catch { // Reload anyway; only the announcement is lost. } - window.location.reload(); + void reloadWhenShellServable(); return; } if ( diff --git a/apps/desktop/src/desktop-deferred-install.ts b/apps/desktop/src/desktop-deferred-install.ts index ebeb9ebed2..d6313bc795 100644 --- a/apps/desktop/src/desktop-deferred-install.ts +++ b/apps/desktop/src/desktop-deferred-install.ts @@ -2,12 +2,14 @@ import { z } from "zod"; import { BB_UPDATE_QUIET_PERIOD_MS } from "@bb/config/self-update"; import type { DesktopAutoUpdateLogger } from "./desktop-auto-update.js"; -const DEFERRED_INSTALL_POLL_INTERVAL_MS = 15_000; +const DEFERRED_INSTALL_POLL_INTERVAL_MS = 5_000; const ACTIVITY_FETCH_TIMEOUT_MS = 5_000; const agentActivityResponseSchema = z .object({ busyThreadCount: z.number().int().min(0), + // Absent on older servers; treated as 0. + queuedThreadCount: z.number().int().min(0).optional(), }) .passthrough(); @@ -79,7 +81,7 @@ export function createDeferredInstallController( } } - async function fetchBusyThreadCount(activityUrl: string): Promise { + async function fetchBusyWorkCount(activityUrl: string): Promise { const controller = new AbortController(); const timeoutHandle = setTimeout( () => controller.abort(), @@ -93,8 +95,12 @@ export function createDeferredInstallController( if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - return agentActivityResponseSchema.parse(await response.json()) - .busyThreadCount; + const activity = agentActivityResponseSchema.parse( + await response.json(), + ); + // Queued follow-ups the sweep is about to start count as busy: a + // relaunch now would orphan them. + return activity.busyThreadCount + (activity.queuedThreadCount ?? 0); } finally { clearTimeout(timeoutHandle); } @@ -122,16 +128,16 @@ export function createDeferredInstallController( return; } - let busyThreadCount: number; + let busyWorkCount: number; try { - busyThreadCount = await fetchBusyThreadCount(probe.activityUrl); + busyWorkCount = await fetchBusyWorkCount(probe.activityUrl); } catch { // Unreachable server counts as busy: don't relaunch on missing data. idleSince = null; return; } - if (busyThreadCount > 0) { + if (busyWorkCount > 0) { idleSince = null; return; } diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 819f9e0b9a..012fae3376 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -19,7 +19,10 @@ import { resolveVoiceTranscriptionEnabled, transcribeVoiceInput, } from "../services/ai/voice-transcription.js"; -import { countBusyThreads } from "../services/system/agent-activity.js"; +import { + countBusyThreads, + countQueuedIdleThreads, +} from "../services/system/agent-activity.js"; import { listSystemProviderInfos, resolveSystemExecutionOptions, @@ -173,6 +176,9 @@ export function registerSystemRoutes( ); get(routes.agentActivity, (context) => - context.json({ busyThreadCount: countBusyThreads(deps.db) }), + context.json({ + busyThreadCount: countBusyThreads(deps.db), + queuedThreadCount: countQueuedIdleThreads(deps.db), + }), ); } diff --git a/apps/server/src/services/system/agent-activity.ts b/apps/server/src/services/system/agent-activity.ts index 8d03002e53..f509304c83 100644 --- a/apps/server/src/services/system/agent-activity.ts +++ b/apps/server/src/services/system/agent-activity.ts @@ -29,14 +29,10 @@ export function countBusyThreads(db: DbQueryConnection): number { } /** - * True when a restart right now cannot interrupt or orphan agent work: no - * busy threads, and no idle thread holding queued messages that the 10s - * auto-send sweep is about to start. This is the gate for skipping the - * update quiet period entirely ("Update now"). + * Idle threads holding queued messages that the 10s auto-send sweep is about + * to start: not busy yet, but a restart now would orphan that follow-up. The + * update watchers treat these as busy. */ -export function isServerAtRest(db: DbConnection): boolean { - return ( - countBusyThreads(db) === 0 && - listIdleThreadsWithQueuedMessages(db).length === 0 - ); +export function countQueuedIdleThreads(db: DbConnection): number { + return listIdleThreadsWithQueuedMessages(db).length; } diff --git a/apps/server/src/services/system/self-update.ts b/apps/server/src/services/system/self-update.ts index d62505c5dc..2724e15942 100644 --- a/apps/server/src/services/system/self-update.ts +++ b/apps/server/src/services/system/self-update.ts @@ -22,10 +22,10 @@ import type { } from "@bb/server-contract"; import { ApiError } from "../../errors.js"; import type { ServerLogger, ServerRuntimeConfig } from "../../types.js"; -import { countBusyThreads, isServerAtRest } from "./agent-activity.js"; +import { countBusyThreads, countQueuedIdleThreads } from "./agent-activity.js"; import type { AppVersionService } from "./app-version.js"; -const DEFAULT_POLL_INTERVAL_MS = 15_000; +const DEFAULT_POLL_INTERVAL_MS = 5_000; // Shared with the desktop shell's deferred relaunch; see the constant's doc. const DEFAULT_QUIET_PERIOD_MS = BB_UPDATE_QUIET_PERIOD_MS; const STAGING_INSTALL_TIMEOUT_MS = 10 * 60 * 1000; @@ -258,10 +258,16 @@ export function createSelfUpdateService( idleSince = null; return; } - // "Update now": scheduled while nothing was running and nothing is - // queued to start — no mid-chain gap to protect, so skip the quiet - // period. Once any busy agent has been seen, the full period applies. - if (!busyObservedSinceArm && isServerAtRest(db)) { + // A queued follow-up the auto-send sweep is about to start counts as + // busy: a restart now would orphan it. Checking it directly is what lets + // the quiet period stay short. + if (countQueuedIdleThreads(db) > 0) { + idleSince = null; + return; + } + // At rest with no busy agent seen since arming ("Update now" while + // idle): nothing to protect, skip the quiet period entirely. + if (!busyObservedSinceArm) { triggerExitForSwap(); return; } diff --git a/apps/server/test/system/agent-activity.test.ts b/apps/server/test/system/agent-activity.test.ts index 2ca223a6ba..319a102079 100644 --- a/apps/server/test/system/agent-activity.test.ts +++ b/apps/server/test/system/agent-activity.test.ts @@ -43,6 +43,7 @@ describe("GET /api/v1/system/agents/activity", () => { expect(response.status).toBe(200); await expect(readJson(response)).resolves.toEqual({ busyThreadCount: 3, + queuedThreadCount: 0, }); }); }); diff --git a/packages/config/src/self-update.ts b/packages/config/src/self-update.ts index 6d0c447090..4174ba3747 100644 --- a/packages/config/src/self-update.ts +++ b/packages/config/src/self-update.ts @@ -28,11 +28,13 @@ export const BB_SELF_UPDATE_PROTOCOL_ENV_NAME = "BB_SELF_UPDATE_PROTOCOL"; /** * How long agents must stay continuously idle before a deferred update * applies. Shared by the server's idle watcher and the desktop shell's - * deferred relaunch so the two policies cannot drift. Sized to comfortably - * exceed the 10s queued-message auto-send sweep, whose dispatch gaps make a - * mid-chain thread look momentarily idle. + * deferred relaunch so the two policies cannot drift. Queued follow-ups — + * the main way a mid-chain thread looks momentarily idle — are checked + * directly on every tick rather than insured against with time, so this + * only needs to cover work with no DB trace yet (e.g. an automation + * reacting to a turn completing a beat after the thread goes idle). */ -export const BB_UPDATE_QUIET_PERIOD_MS = 45_000; +export const BB_UPDATE_QUIET_PERIOD_MS = 10_000; export const selfUpdateSentinelSchema = z.object({ /** bb-app version staged and ready to swap to. */ diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index c376c4bebe..b8789d712c 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -176,6 +176,12 @@ export type SystemVersionResponse = z.infer; export const systemAgentActivityResponseSchema = z.object({ /** Threads currently starting, active, or stopping. */ busyThreadCount: z.number().int().min(0), + /** + * Idle threads holding queued follow-ups the auto-send sweep is about to + * start. Not busy yet, but a restart now would orphan that work, so update + * tooling treats them as busy. + */ + queuedThreadCount: z.number().int().min(0), }); export type SystemAgentActivityResponse = z.infer< typeof systemAgentActivityResponseSchema