From d6e17a94a5ea5a0561bda3be7f5747ab3408e42a Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 18 Jul 2026 14:52:13 +0000 Subject: [PATCH 1/6] fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hub↔runner protocol drift was reported as missing Cursor chat data when cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors, advertise required machine capabilities, surface an unmissable upgrade banner, and stop-runner when a newer CLI binary is already on disk. Fixes #1084 Co-authored-by: Cursor --- cli/src/agent/sessionFactory.test.ts | 3 +- cli/src/agent/sessionFactory.ts | 15 +- cli/src/api/apiMachine.ts | 14 ++ cli/src/runner/run.ts | 2 +- docs/guide/installation.md | 4 + hub/src/sync/rpcGateway.ts | 4 + hub/src/sync/runnerEnsure.test.ts | 109 ++++++++++++++ hub/src/sync/sessionModel.test.ts | 53 +++++++ hub/src/sync/syncEngine.ts | 57 ++++++- shared/package.json | 1 + shared/src/index.ts | 1 + shared/src/runnerCapabilities.test.ts | 48 ++++++ shared/src/runnerCapabilities.ts | 58 +++++++ shared/src/schemas.ts | 8 +- web/src/App.tsx | 2 + .../components/NewSession/MachineSelector.tsx | 16 +- .../RunnerVersionSkewBanner.test.tsx | 142 ++++++++++++++++++ .../components/RunnerVersionSkewBanner.tsx | 66 ++++++++ web/src/components/SessionActionMenu.test.tsx | 16 ++ web/src/components/SessionActionMenu.tsx | 13 +- web/src/components/SessionChat.tsx | 2 + web/src/components/SessionHeader.tsx | 2 + web/src/components/SessionList.tsx | 22 ++- web/src/lib/locales/en.ts | 4 + web/src/lib/locales/zh-CN.ts | 4 + web/src/lib/sessionResume.test.ts | 37 ++++- web/src/lib/sessionResume.ts | 37 ++++- web/src/router.tsx | 23 ++- 28 files changed, 730 insertions(+), 33 deletions(-) create mode 100644 hub/src/sync/runnerEnsure.test.ts create mode 100644 shared/src/runnerCapabilities.test.ts create mode 100644 web/src/components/RunnerVersionSkewBanner.test.tsx create mode 100644 web/src/components/RunnerVersionSkewBanner.tsx diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 01789e1a67..2460e76501 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -29,7 +29,8 @@ vi.mock('@/api/api', () => ({ })) vi.mock('@/runner/controlClient', () => ({ - notifyRunnerSessionStarted: notifyRunnerSessionStartedMock + notifyRunnerSessionStarted: notifyRunnerSessionStartedMock, + getInstalledCliMtimeMs: () => 1_700_000_000_000, })) vi.mock('@/persistence', () => ({ diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index c7fbbd1b7a..4d9c88afd2 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -5,13 +5,14 @@ import { resolve } from 'node:path' import { ApiClient } from '@/api/api' import type { ApiSessionClient } from '@/api/apiSession' import type { AgentState, MachineMetadata, Metadata, Session } from '@/api/types' -import { notifyRunnerSessionStarted } from '@/runner/controlClient' +import { getInstalledCliMtimeMs, notifyRunnerSessionStarted } from '@/runner/controlClient' import { readSettings } from '@/persistence' import { configuration } from '@/configuration' import { logger } from '@/ui/logger' import { runtimePath } from '@/projectPath' import { getInvokedCwd } from '@/utils/invokedCwd' import { readWorktreeEnv } from '@/utils/worktreeEnv' +import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv' import packageJson from '../../package.json' @@ -41,7 +42,12 @@ export type SessionBootstrapResult = { workingDirectory: string } -export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): MachineMetadata { +export function buildMachineMetadata(options?: { + workspaceRoots?: string[] + startedCliMtimeMs?: number +}): MachineMetadata { + const installedCliMtimeMs = getInstalledCliMtimeMs() + const startedCliMtimeMs = options?.startedCliMtimeMs ?? installedCliMtimeMs return { host: process.env.HAPI_HOSTNAME || os.hostname(), platform: os.platform(), @@ -49,7 +55,10 @@ export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): M homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, happyLibDir: runtimePath(), - workspaceRoots: options?.workspaceRoots + workspaceRoots: options?.workspaceRoots, + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + ...(typeof startedCliMtimeMs === 'number' ? { startedCliMtimeMs } : {}), + ...(typeof installedCliMtimeMs === 'number' ? { installedCliMtimeMs } : {}), } } diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index dc5bdf8329..2d7a6632a9 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -24,6 +24,7 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import { RUNNER_CAPABILITIES } from '@hapi/protocol' import type { RunnerState, Machine, MachineMetadata } from './types' import { RunnerStateSchema, MachineMetadataSchema } from './types' +import { getInstalledCliMtimeMs } from '@/runner/controlClient' import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' @@ -656,6 +657,19 @@ export class ApiMachineClient { time: Date.now(), health: collectMachineHealth() }) + const installedCliMtimeMs = getInstalledCliMtimeMs() + if ( + typeof installedCliMtimeMs === 'number' + && this.machine.metadata + && this.machine.metadata.installedCliMtimeMs !== installedCliMtimeMs + ) { + void this.updateMachineMetadata((current) => ({ + ...(current ?? this.machine.metadata!), + installedCliMtimeMs, + })).catch((error) => { + logger.debug('[API MACHINE] Failed to refresh installedCliMtimeMs', error) + }) + } } // Prime CPU sampling so the first heartbeat already includes CPU %. collectMachineHealth() diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 8de1db3f30..9c91b5cfb3 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1121,7 +1121,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const machine = await withRetry( () => api.getOrCreateMachine({ machineId, - metadata: buildMachineMetadata({ workspaceRoots }), + metadata: buildMachineMetadata({ workspaceRoots, startedCliMtimeMs: startedWithCliMtimeMs }), runnerState: initialRunnerState }), { diff --git a/docs/guide/installation.md b/docs/guide/installation.md index e208dc31bd..6980d9d02c 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -326,6 +326,10 @@ hapi runner start --workspace-root ~/projects --workspace-root ~/work For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). +### Multi-machine hubs + +You can run **one hub** and **runners on many machines** (each machine installs its own CLI). When you upgrade the hub, upgrade the HAPI CLI on every machine that parents sessions, then restart that machine's runner (`systemctl --user restart hapi-runner` or `hapi runner start`). Until a runner reports the capabilities the hub requires, the web UI shows a persistent **Runner out of date** banner with the host name and upgrade steps — do not ignore it (Cursor reopen and similar features depend on matching generations). + ## Security notes - Keep tokens secret and rotate if needed diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 259155a8c4..405dadafc7 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -279,6 +279,10 @@ export class RpcGateway { return CursorChatStoreStatusSchema.parse(result) } + async stopRunner(machineId: string): Promise { + await this.machineRpc(machineId, RPC_METHODS.StopRunner, {}) + } + async getGitStatus(sessionId: string, cwd?: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.GitStatus, { cwd }) as RpcCommandResponse } diff --git a/hub/src/sync/runnerEnsure.test.ts b/hub/src/sync/runnerEnsure.test.ts new file mode 100644 index 0000000000..d9d842aaec --- /dev/null +++ b/hub/src/sync/runnerEnsure.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, mock } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' +import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' + +describe('SyncEngine runner generation ensure', () => { + it('calls stop-runner when skewed and on-disk CLI mtime differs', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'stale-runner', + { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + capabilities: [], + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'stale-runner', time: Date.now() }) + + await new Promise((r) => setTimeout(r, 20)) + expect(stopRunner).toHaveBeenCalledWith('stale-runner') + } finally { + engine.stop() + } + }) + + it('does not call stop-runner when skewed but binary mtime is unchanged', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'old-runner', + { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + startedCliMtimeMs: 100, + installedCliMtimeMs: 100, + }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'old-runner', time: Date.now() }) + await new Promise((r) => setTimeout(r, 20)) + expect(stopRunner).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) + + it('does not call stop-runner when required capabilities are present', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'current-runner', + { + host: 'oos', + platform: 'linux', + happyCliVersion: '0.23.0', + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'current-runner', time: Date.now() }) + await new Promise((r) => setTimeout(r, 20)) + expect(stopRunner).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 7a3fea136a..9886076d05 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -3226,6 +3226,59 @@ describe('session model', () => { } }) + it('soft-fails Cursor reopen when chat-store probe throws (missing handler / skew)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'cursor-probe-skew-reopen', + { + path: '/tmp/project', + host: 'cursor-host', + machineId: 'cursor-machine', + homeDir: '/home/cursor-owner', + flavor: 'cursor', + cursorSessionId: 'cursor-thread-skew', + cursorSessionProtocol: 'acp' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'cursor-machine', + { host: 'cursor-host', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'cursor-machine', time: Date.now() }) + + let spawnCalled = false + ;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => { + throw new Error('RPC handler not registered: cursor-machine:cursor-chat-store-status') + } + ;(engine as any).rpcGateway.spawnSession = async () => { + spawnCalled = true + engine.handleSessionAlive({ sid: session.id, time: Date.now() }) + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionReady = async () => 'ready' + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(spawnCalled).toBe(true) + } finally { + engine.stop() + } + }) + it('probes Cursor chat data on the session recorded machine', async () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 92f38fb91d..5b137598ce 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,6 +8,10 @@ */ import { isKnownFlavor, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' +import { + cliBinaryUpdatedOnDisk, + isMachineCapabilitySkewed, +} from '@hapi/protocol/runnerCapabilities' import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' @@ -191,6 +195,9 @@ export class SyncEngine { * Defaults to "1" for unit tests; startHub overwrites with getOrCreateOwnerId(). */ private hubOwnerUserId: string = '1' + /** Rate-limit hub-initiated stop-runner ensure attempts (machineId → last attempt ms). */ + private readonly runnerEnsureAttemptAt = new Map() + private static readonly RUNNER_ENSURE_COOLDOWN_MS = 5 * 60_000 constructor( private readonly store: Store, @@ -463,6 +470,7 @@ export class SyncEngine { if (event.type === 'machine-updated' && event.machineId) { this.machineCache.refreshMachine(event.machineId) + void this.maybeEnsureRunnerGeneration(event.machineId) return } @@ -872,6 +880,41 @@ async uploadScratchlistAttachment( handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void { this.machineCache.handleMachineAlive(payload) + void this.maybeEnsureRunnerGeneration(payload.machineId) + } + + /** + * When a connected runner is missing required capabilities but a newer CLI + * binary is already on disk, ask it to stop so systemd/handoff loads the + * new generation. If the binary is not updated, the web skew banner stays. + */ + private async maybeEnsureRunnerGeneration(machineId: string): Promise { + const machine = this.machineCache.getMachine(machineId) + if (!machine?.active || !machine.metadata) { + return + } + if (!isMachineCapabilitySkewed(machine.metadata.capabilities)) { + return + } + if (!cliBinaryUpdatedOnDisk(machine.metadata)) { + return + } + const lastAttempt = this.runnerEnsureAttemptAt.get(machineId) ?? 0 + if (Date.now() - lastAttempt < SyncEngine.RUNNER_ENSURE_COOLDOWN_MS) { + return + } + this.runnerEnsureAttemptAt.set(machineId, Date.now()) + try { + console.warn('[runner-ensure] Stopping skewed runner so a newer on-disk CLI can load', { + machineId, + host: machine.metadata.host, + version: machine.metadata.happyCliVersion, + }) + await this.rpcGateway.stopRunner(machineId) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.warn('[runner-ensure] stop-runner failed', { machineId, message }) + } } private expireInactive(): void { @@ -2785,11 +2828,15 @@ async uploadScratchlistAttachment( } } } catch (error) { - return { - type: 'error', - message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store', - code: 'resume_failed' - } + // Soft-fail on probe skew / missing handler (#1084): definitive + // onDisk:false still blocks above; probe errors must not be + // reported as missing chat data. + const message = error instanceof Error ? error.message : 'Failed to inspect Cursor chat store' + console.warn('[resume] Cursor chat-store probe failed; proceeding with reopen attempt', { + sessionId: access.sessionId, + machineId: targetMachine.id, + message + }) } } diff --git a/shared/package.json b/shared/package.json index b1e8edfe28..760d72d22f 100644 --- a/shared/package.json +++ b/shared/package.json @@ -14,6 +14,7 @@ "./conversationHistory": "./src/conversationHistory.ts", "./modes": "./src/modes.ts", "./rpcMethods": "./src/rpcMethods.ts", + "./runnerCapabilities": "./src/runnerCapabilities.ts", "./schemas": "./src/schemas.ts", "./sessionCitation": "./src/sessionCitation.ts", "./sessionExport": "./src/sessionExport.ts", diff --git a/shared/src/index.ts b/shared/src/index.ts index 714001a097..824e0d55c6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -11,6 +11,7 @@ export * from './models' export * from './modes' export * from './resume' export * from './rpcMethods' +export * from './runnerCapabilities' export * from './socket' export * from './sessionSummary' export * from './sessionCitation' diff --git a/shared/src/runnerCapabilities.test.ts b/shared/src/runnerCapabilities.test.ts new file mode 100644 index 0000000000..d9404b0f4c --- /dev/null +++ b/shared/src/runnerCapabilities.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + CURRENT_MACHINE_CAPABILITIES, + MACHINE_CAPABILITIES, + REQUIRED_MACHINE_CAPABILITIES, + cliBinaryUpdatedOnDisk, + isMachineCapabilitySkewed, + missingRequiredCapabilities, +} from './runnerCapabilities' + +describe('runnerCapabilities', () => { + it('requires cursor-chat-store-status so hub features cannot fail-closed without a registry entry', () => { + expect(REQUIRED_MACHINE_CAPABILITIES).toContain(MACHINE_CAPABILITIES.CursorChatStoreStatus) + expect(CURRENT_MACHINE_CAPABILITIES).toEqual(expect.arrayContaining([ + ...REQUIRED_MACHINE_CAPABILITIES, + ])) + }) + + it('treats missing/empty advertised capabilities as skewed', () => { + expect(isMachineCapabilitySkewed(undefined)).toBe(true) + expect(isMachineCapabilitySkewed(null)).toBe(true) + expect(isMachineCapabilitySkewed([])).toBe(true) + expect(missingRequiredCapabilities([])).toEqual([ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + ]) + }) + + it('is not skewed when required capabilities are advertised', () => { + expect(isMachineCapabilitySkewed([...CURRENT_MACHINE_CAPABILITIES])).toBe(false) + expect(missingRequiredCapabilities([ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + 'other-cap', + ])).toEqual([]) + }) + + it('detects on-disk CLI binary updates via mtime drift', () => { + expect(cliBinaryUpdatedOnDisk({ + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + })).toBe(true) + expect(cliBinaryUpdatedOnDisk({ + startedCliMtimeMs: 100, + installedCliMtimeMs: 100, + })).toBe(false) + expect(cliBinaryUpdatedOnDisk({})).toBe(false) + expect(cliBinaryUpdatedOnDisk(null)).toBe(false) + }) +}) diff --git a/shared/src/runnerCapabilities.ts b/shared/src/runnerCapabilities.ts index 4b2c59b8b5..f51c253eda 100644 --- a/shared/src/runnerCapabilities.ts +++ b/shared/src/runnerCapabilities.ts @@ -1,3 +1,5 @@ +import { RPC_METHODS } from './rpcMethods' + /** * Capabilities the current runner generation advertises to the hub. * @@ -15,3 +17,59 @@ export const RUNNER_CAPABILITIES = { } as const export type RunnerCapabilities = typeof RUNNER_CAPABILITIES + +/** + * Machine-scoped capabilities runners advertise on connect. + * Hub features that hard-depend on a machine RPC must list that capability + * in {@link REQUIRED_MACHINE_CAPABILITIES} so skew surfaces as a banner + * instead of a silent fail-closed product bug. + */ +export const MACHINE_CAPABILITIES = { + CursorChatStoreStatus: RPC_METHODS.CursorChatStoreStatus, + StopRunner: RPC_METHODS.StopRunner, +} as const + +export type MachineCapability = + (typeof MACHINE_CAPABILITIES)[keyof typeof MACHINE_CAPABILITIES] + +/** Capabilities this CLI generation registers on the machine socket. */ +export const CURRENT_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.CursorChatStoreStatus, + MACHINE_CAPABILITIES.StopRunner, +] + +/** + * Capabilities the hub requires on every connected runner for features it + * hard-depends on. Missing entries → operator-visible skew banner (+ optional + * stop-runner ensure when a newer binary is already on disk). + */ +export const REQUIRED_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ + MACHINE_CAPABILITIES.CursorChatStoreStatus, +] + +export function missingRequiredCapabilities( + advertised: readonly string[] | null | undefined, +): MachineCapability[] { + const set = new Set(advertised ?? []) + return REQUIRED_MACHINE_CAPABILITIES.filter((cap) => !set.has(cap)) +} + +export function isMachineCapabilitySkewed( + advertised: readonly string[] | null | undefined, +): boolean { + return missingRequiredCapabilities(advertised).length > 0 +} + +/** True when the running process started from a different CLI binary/mtime than what's installed now. */ +export function cliBinaryUpdatedOnDisk(metadata: { + startedCliMtimeMs?: number | null + installedCliMtimeMs?: number | null +} | null | undefined): boolean { + const started = metadata?.startedCliMtimeMs + const installed = metadata?.installedCliMtimeMs + return typeof started === 'number' + && typeof installed === 'number' + && Number.isFinite(started) + && Number.isFinite(installed) + && started !== installed +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index dc9288ae31..f72aca7707 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -432,7 +432,13 @@ export const MachineMetadataSchema = z.object({ homeDir: z.string().optional(), happyHomeDir: z.string().optional(), happyLibDir: z.string().optional(), - workspaceRoots: z.array(z.string()).optional() + workspaceRoots: z.array(z.string()).optional(), + /** Machine-scoped RPC capability ids this runner registers (see runnerCapabilities). */ + capabilities: z.array(z.string()).optional(), + /** CLI binary/package mtime when this runner process started. */ + startedCliMtimeMs: z.number().optional(), + /** Current on-disk CLI binary/package mtime (may differ after upgrade). */ + installedCliMtimeMs: z.number().optional(), }) export type MachineMetadata = z.infer diff --git a/web/src/App.tsx b/web/src/App.tsx index 3f9ea2c394..c71f5cb453 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,7 @@ import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/P import { SyncingBanner } from '@/components/SyncingBanner' import { ReconnectingBanner } from '@/components/ReconnectingBanner' import { VoiceErrorBanner } from '@/components/VoiceErrorBanner' +import { RunnerVersionSkewBanner } from '@/components/RunnerVersionSkewBanner' import { LoadingState } from '@/components/LoadingState' import { ToastContainer } from '@/components/ToastContainer' import { PwaUpdateProvider } from '@/lib/pwa-update-context' @@ -478,6 +479,7 @@ function AppInner() { isHubConnected={globalSubscriptionId !== null} isReconnecting={showReconnectingBanner} /> +
diff --git a/web/src/components/NewSession/MachineSelector.tsx b/web/src/components/NewSession/MachineSelector.tsx index 1ee076e476..0fc97aff3b 100644 --- a/web/src/components/NewSession/MachineSelector.tsx +++ b/web/src/components/NewSession/MachineSelector.tsx @@ -1,4 +1,5 @@ import type { Machine } from '@/types/api' +import { isMachineCapabilitySkewed } from '@hapi/protocol/runnerCapabilities' import { useTranslation } from '@/lib/use-translation' import { SelectControl } from '@/components/ui/select-control' @@ -8,6 +9,18 @@ function getMachineTitle(machine: Machine): string { return machine.id.slice(0, 8) } +function getMachineOptionLabel(machine: Machine): string { + const title = getMachineTitle(machine) + const platform = machine.metadata?.platform ? ` (${machine.metadata.platform})` : '' + const version = machine.metadata?.happyCliVersion + ? ` · CLI ${machine.metadata.happyCliVersion}` + : '' + const skew = machine.active && isMachineCapabilitySkewed(machine.metadata?.capabilities) + ? ' · UPDATE REQUIRED' + : '' + return `${title}${platform}${version}${skew}` +} + export function MachineSelector(props: { machines: Machine[] machineId: string | null @@ -36,8 +49,7 @@ export function MachineSelector(props: { )} {props.machines.map((m) => ( ))} diff --git a/web/src/components/RunnerVersionSkewBanner.test.tsx b/web/src/components/RunnerVersionSkewBanner.test.tsx new file mode 100644 index 0000000000..3621f10497 --- /dev/null +++ b/web/src/components/RunnerVersionSkewBanner.test.tsx @@ -0,0 +1,142 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' +import type { Machine } from '@/types/api' +import { + RunnerVersionSkewBanner, + listSkewedMachines, + machineDisplayHost, +} from './RunnerVersionSkewBanner' +import { I18nProvider } from '@/lib/i18n-context' + +const useMachinesMock = vi.fn() +const useAppContextMock = vi.fn(() => ({ + api: {} as never, + token: 't', + baseUrl: 'http://localhost', +})) + +vi.mock('@/hooks/queries/useMachines', () => ({ + useMachines: (...args: unknown[]) => useMachinesMock(...args), +})) + +vi.mock('@/lib/app-context', () => ({ + useAppContext: () => useAppContextMock(), +})) + +vi.mock('@/hooks/useOnlineStatus', () => ({ + useOnlineStatus: () => true, +})) + +function makeMachine(overrides: Partial & { id: string }): Machine { + const { id, ...rest } = overrides + return { + id, + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: rest.active ?? true, + activeAt: Date.now(), + metadata: rest.metadata ?? { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 0, + ...rest, + } as Machine +} + +describe('listSkewedMachines', () => { + it('flags online machines without required capabilities', () => { + const skewed = listSkewedMachines([ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + makeMachine({ + id: 'new', + metadata: { + host: 'oos', + platform: 'linux', + happyCliVersion: '0.23.0', + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + }, + }), + makeMachine({ + id: 'offline-old', + active: false, + metadata: { host: 'ha', platform: 'linux', happyCliVersion: '0.19.0' }, + }), + ]) + expect(skewed.map((m) => m.id)).toEqual(['old']) + }) + + it('uses displayName when present', () => { + expect(machineDisplayHost(makeMachine({ + id: 'm1', + metadata: { + host: 'proxmox.local', + platform: 'linux', + happyCliVersion: '0.20.0', + displayName: 'Proxmox box', + }, + }))).toBe('Proxmox box') + }) +}) + +describe('RunnerVersionSkewBanner', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders an unmissable banner for skewed online machines', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(screen.getByTestId('runner-version-skew-banner')).toBeInTheDocument() + expect(screen.getByText(/Runner out of date on proxmox/)).toBeInTheDocument() + expect(screen.getByText(/Upgrade the CLI on that machine/)).toBeInTheDocument() + expect(screen.getByText(/0\.20\.0/)).toBeInTheDocument() + }) + + it('hides when all online machines advertise required capabilities', async () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'new', + metadata: { + host: 'oos', + platform: 'linux', + happyCliVersion: '0.23.0', + capabilities: [...CURRENT_MACHINE_CAPABILITIES], + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/RunnerVersionSkewBanner.tsx b/web/src/components/RunnerVersionSkewBanner.tsx new file mode 100644 index 0000000000..e5fda5073b --- /dev/null +++ b/web/src/components/RunnerVersionSkewBanner.tsx @@ -0,0 +1,66 @@ +import { isMachineCapabilitySkewed } from '@hapi/protocol/runnerCapabilities' +import type { Machine } from '@/types/api' +import { useMachines } from '@/hooks/queries/useMachines' +import { useTranslation } from '@/lib/use-translation' +import { useAppContext } from '@/lib/app-context' +import { useOnlineStatus } from '@/hooks/useOnlineStatus' + +export function machineDisplayHost(machine: Machine): string { + return machine.metadata?.displayName + ?? machine.metadata?.host + ?? machine.id +} + +export function listSkewedMachines(machines: Machine[]): Machine[] { + return machines.filter((machine) => ( + machine.active + && isMachineCapabilitySkewed(machine.metadata?.capabilities) + )) +} + +/** + * Persistent, non-dismissible banner when any online machine is missing + * hub-required runner capabilities (#1084). + */ +export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: string } = {}) { + const { api } = useAppContext() + const { machines } = useMachines(api, true) + const { t } = useTranslation() + const isOnline = useOnlineStatus() + const skewed = listSkewedMachines(machines) + + if (skewed.length === 0) { + return null + } + + const topClass = topClassName ?? (isOnline ? 'top-2' : 'top-10') + + return ( +
+ {skewed.map((machine) => { + const host = machineDisplayHost(machine) + const version = machine.metadata?.happyCliVersion + return ( +
+

+ {t('runner.skew.banner.title', { host })} +

+

+ {version + ? t('runner.skew.banner.body', { host, version }) + : t('runner.skew.banner.bodyUnknownVersion', { host })} +

+
+ ) + })} +
+ ) +} diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx index 5ac0f5f6a6..f89a4c5960 100644 --- a/web/src/components/SessionActionMenu.test.tsx +++ b/web/src/components/SessionActionMenu.test.tsx @@ -112,6 +112,22 @@ describe('SessionActionMenu - Reopen action', () => { expect(onClose).not.toHaveBeenCalled() }) + it('keeps Reopen enabled with a soft-fail hint when probe is unverified', () => { + const onReopen = vi.fn() + renderMenu({ + sessionActive: false, + onReopen, + reopenHint: 'Could not verify Cursor chat data (runner may be outdated).', + }) + + const reopen = screen.getByRole('menuitem', { name: /Reopen/ }) + expect(reopen).not.toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('tooltip')).toHaveTextContent('Could not verify Cursor chat data') + + fireEvent.click(reopen) + expect(onReopen).toHaveBeenCalledTimes(1) + }) + it('fires onReopen and closes the menu when the Reopen item is clicked', () => { const onReopen = vi.fn() const onClose = vi.fn() diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index f7e66aa906..49ae993486 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -30,6 +30,8 @@ type SessionActionMenuProps = { onArchive: () => void onReopen?: () => void reopenDisabledReason?: string + /** Soft-fail tip when reopen is allowed but chat-store probe could not verify. */ + reopenHint?: string onDelete: () => void anchorPoint: { x: number; y: number } menuId?: string @@ -199,6 +201,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onArchive, onReopen, reopenDisabledReason, + reopenHint, onDelete, anchorPoint, menuId @@ -456,7 +459,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { ) : ( <> - {onReopen || reopenDisabledReason ? ( + {onReopen || reopenDisabledReason || reopenHint ? ( )} > - {reopenDisabledReason ?? t('session.action.reopen')} + {reopenDisabledReason ?? reopenHint ?? t('session.action.reopen')} ) : null} + + ) + } return (
- {skewed.map((machine) => { - const host = machineDisplayHost(machine) - const version = machine.metadata?.happyCliVersion - return ( -
+
+

+ {t('runner.skew.banner.summaryTitle', { count: skewed.length })} +

+

+ {t('runner.skew.banner.summaryBody')} +

+
+
+ +
- ) - })} + {t('runner.skew.banner.dismissTemp')} + +
+
+ +
    + {skewed.map((machine) => { + const host = machineDisplayHost(machine) + const version = machine.metadata?.happyCliVersion + const canAutoHint = cliBinaryUpdatedOnDisk(machine.metadata) + return ( +
  • +
    +
    + {host} + {version ? ` · CLI ${version}` : null} + {canAutoHint ? ( + + {t('runner.skew.banner.binaryUpdatedHint')} + + ) : null} +
    + +
    +
  • + ) + })} +
+ + {restartError ? ( +

+ {restartError} +

+ ) : null} + +

+ {t('runner.skew.banner.autoUpgradeHint')} +

) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 90ee3e9237..7199ccb616 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -246,6 +246,16 @@ export default { 'runner.skew.banner.title': 'Runner out of date on {host}', 'runner.skew.banner.body': 'This hub needs a newer HAPI CLI on {host} (running {version}). Upgrade the CLI on that machine, then restart the runner (`systemctl --user restart hapi-runner` or `hapi runner start`). This banner stays until the runner reports the required capabilities.', 'runner.skew.banner.bodyUnknownVersion': 'This hub needs a newer HAPI CLI on {host}. Upgrade the CLI on that machine, then restart the runner (`systemctl --user restart hapi-runner` or `hapi runner start`). This banner stays until the runner reports the required capabilities.', + 'runner.skew.banner.summaryTitle': '{count} runner(s) out of date', + 'runner.skew.banner.summaryBody': 'Upgrade the HAPI CLI on each host below, then restart that runner. Minimize or snooze this banner to reach sessions underneath.', + 'runner.skew.banner.minimized': '{count} outdated runner(s): {hosts} — tap to expand', + 'runner.skew.banner.minimize': 'Minimize', + 'runner.skew.banner.dismissTemp': 'Snooze 1h', + 'runner.skew.banner.restart': 'Restart runner', + 'runner.skew.banner.restarting': 'Restarting…', + 'runner.skew.banner.binaryUpdatedHint': '(newer CLI on disk — restart may pick it up)', + 'runner.skew.banner.autoUpgradeHint': 'Opt-in auto-restart when a newer CLI is already on disk: set HAPI_AUTO_UPGRADE_RUNNERS=1 (or autoUpgradeRunners: true in hub settings.json). Off by default. Does not push packages to remote machines.', + 'runner.skew.restartFailed': 'Failed to restart runner', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', 'session.action.copyReference': 'Copy reference', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d0572d9462..96ffba93a9 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -246,6 +246,16 @@ export default { 'runner.skew.banner.title': '{host} 上的运行器版本过旧', 'runner.skew.banner.body': '此 Hub 需要 {host} 上更新的 HAPI CLI(当前 {version})。请在该机器上升级 CLI,然后重启运行器(`systemctl --user restart hapi-runner` 或 `hapi runner start`)。在运行器报告所需能力之前,此横幅会一直显示。', 'runner.skew.banner.bodyUnknownVersion': '此 Hub 需要 {host} 上更新的 HAPI CLI。请在该机器上升级 CLI,然后重启运行器(`systemctl --user restart hapi-runner` 或 `hapi runner start`)。在运行器报告所需能力之前,此横幅会一直显示。', + 'runner.skew.banner.summaryTitle': '{count} 个运行器版本过旧', + 'runner.skew.banner.summaryBody': '请在下列每台主机上升级 HAPI CLI 并重启运行器。可最小化或 snooze 本横幅以操作下方会话。', + 'runner.skew.banner.minimized': '{count} 个过旧运行器:{hosts} — 点按展开', + 'runner.skew.banner.minimize': '最小化', + 'runner.skew.banner.dismissTemp': '暂隐 1 小时', + 'runner.skew.banner.restart': '重启运行器', + 'runner.skew.banner.restarting': '正在重启…', + 'runner.skew.banner.binaryUpdatedHint': '(磁盘上已有更新 CLI — 重启可能加载)', + 'runner.skew.banner.autoUpgradeHint': '可选:磁盘上已有更新 CLI 时由 Hub 自动重启运行器。设置 HAPI_AUTO_UPGRADE_RUNNERS=1(或 settings.json 中 autoUpgradeRunners: true)。默认关闭。不会向远程机器推送安装包。', + 'runner.skew.restartFailed': '重启运行器失败', 'session.action.delete': '删除', 'session.action.copy': '复制', 'session.action.copyReference': '复制引用', diff --git a/web/src/lib/runnerSkewBannerState.ts b/web/src/lib/runnerSkewBannerState.ts new file mode 100644 index 0000000000..aba3770693 --- /dev/null +++ b/web/src/lib/runnerSkewBannerState.ts @@ -0,0 +1,51 @@ +const MINIMIZED_KEY = 'hapi.runnerSkew.minimized.v1' +const DISMISS_UNTIL_KEY = 'hapi.runnerSkew.dismissUntil.v1' +export const RUNNER_SKEW_TEMP_DISMISS_MS = 60 * 60_000 + +function readStorage(): Storage | null { + if (typeof window === 'undefined') { + return null + } + try { + return window.sessionStorage + } catch { + return null + } +} + +export function isRunnerSkewMinimized(): boolean { + return readStorage()?.getItem(MINIMIZED_KEY) === '1' +} + +export function setRunnerSkewMinimized(minimized: boolean): void { + const storage = readStorage() + if (!storage) { + return + } + if (minimized) { + storage.setItem(MINIMIZED_KEY, '1') + } else { + storage.removeItem(MINIMIZED_KEY) + } +} + +export function getRunnerSkewDismissUntil(): number { + const raw = readStorage()?.getItem(DISMISS_UNTIL_KEY) + if (!raw) { + return 0 + } + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : 0 +} + +export function isRunnerSkewTempDismissed(now: number = Date.now()): boolean { + return getRunnerSkewDismissUntil() > now +} + +export function tempDismissRunnerSkew(now: number = Date.now()): void { + readStorage()?.setItem(DISMISS_UNTIL_KEY, String(now + RUNNER_SKEW_TEMP_DISMISS_MS)) +} + +export function clearRunnerSkewTempDismiss(): void { + readStorage()?.removeItem(DISMISS_UNTIL_KEY) +} From 8ec06fe3c25fd7019b19e14187d490e1c4e52830 Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 18 Jul 2026 17:10:53 +0000 Subject: [PATCH 3/6] fix(web): tolerate full sessionStorage on skew banner minimize QuotaExceededError from setItem aborted minimize before React state updated, leaving the banner stuck over the session list. Persist to memory when storage fails; only enable Restart when a newer CLI is already on disk; clarify opt-in is stop-runner only, not package push. Co-authored-by: Cursor --- docs/guide/installation.md | 2 +- .../RunnerVersionSkewBanner.test.tsx | 57 ++++++++++++++- .../components/RunnerVersionSkewBanner.tsx | 27 ++++--- web/src/lib/locales/en.ts | 7 +- web/src/lib/locales/zh-CN.ts | 7 +- web/src/lib/runnerSkewBannerState.test.ts | 51 +++++++++++++ web/src/lib/runnerSkewBannerState.ts | 72 +++++++++++++++---- 7 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 web/src/lib/runnerSkewBannerState.test.ts diff --git a/docs/guide/installation.md b/docs/guide/installation.md index e9cfd443ec..55d55f65fc 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -340,7 +340,7 @@ HAPI_AUTO_UPGRADE_RUNNERS=1 # "autoUpgradeRunners": true ``` -Off by default. The banner also has a per-host **Restart runner** button for manual stop-runner. Installing/upgrading the CLI package on remote hosts remains your responsibility. +Off by default. This does **not** download, `npm install`, or push packages to remotes — you still upgrade the CLI on each host yourself. Opt-in only means: when the hub detects skew **and** a newer on-disk binary (mtime differs from the running process), it may `stop-runner` so the existing install is loaded. The banner’s per-host **Restart runner** button is the same action, manual and only enabled when a newer binary is already on disk. ## Security notes diff --git a/web/src/components/RunnerVersionSkewBanner.test.tsx b/web/src/components/RunnerVersionSkewBanner.test.tsx index 7af3393d21..b5be416384 100644 --- a/web/src/components/RunnerVersionSkewBanner.test.tsx +++ b/web/src/components/RunnerVersionSkewBanner.test.tsx @@ -10,6 +10,7 @@ import { import { I18nProvider } from '@/lib/i18n-context' import { clearRunnerSkewTempDismiss, + resetRunnerSkewBannerMemoryForTests, setRunnerSkewMinimized, } from '@/lib/runnerSkewBannerState' @@ -99,6 +100,7 @@ describe('listSkewedMachines', () => { describe('RunnerVersionSkewBanner', () => { beforeEach(() => { window.sessionStorage.clear() + resetRunnerSkewBannerMemoryForTests() setRunnerSkewMinimized(false) clearRunnerSkewTempDismiss() restartMachineRunnerMock.mockClear() @@ -170,7 +172,7 @@ describe('RunnerVersionSkewBanner', () => { expect(screen.queryByTestId('runner-version-skew-banner')).not.toBeInTheDocument() }) - it('calls restartMachineRunner when Restart is clicked', async () => { + it('disables Restart when no newer CLI is on disk', () => { useMachinesMock.mockReturnValue({ machines: [ makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), @@ -185,12 +187,65 @@ describe('RunnerVersionSkewBanner', () => { , ) + const restart = screen.getByTestId('runner-version-skew-restart-old') + expect(restart).toBeDisabled() + expect(restart).toHaveTextContent(/Upgrade CLI first/) + }) + + it('calls restartMachineRunner when Restart is clicked and newer CLI is on disk', async () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'old', + metadata: { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + fireEvent.click(screen.getByTestId('runner-version-skew-restart-old')) await waitFor(() => { expect(restartMachineRunnerMock).toHaveBeenCalledWith('old') }) }) + it('minimizes even when sessionStorage setItem throws QuotaExceededError', () => { + const proto = Object.getPrototypeOf(window.sessionStorage) as Storage + vi.spyOn(proto, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ id: 'old', metadata: { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' } }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(() => fireEvent.click(screen.getByTestId('runner-version-skew-minimize'))).not.toThrow() + expect(screen.getByTestId('runner-version-skew-banner')).toHaveAttribute('data-state', 'minimized') + }) + it('hides when all online machines advertise required capabilities', async () => { useMachinesMock.mockReturnValue({ machines: [ diff --git a/web/src/components/RunnerVersionSkewBanner.tsx b/web/src/components/RunnerVersionSkewBanner.tsx index 2539e27c1c..a40f13026e 100644 --- a/web/src/components/RunnerVersionSkewBanner.tsx +++ b/web/src/components/RunnerVersionSkewBanner.tsx @@ -64,20 +64,21 @@ export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: strin const onMinimize = useCallback(() => { haptic.impact('light') - setRunnerSkewMinimized(true) + // UI first — storage may throw QuotaExceededError on full sessionStorage. setMinimized(true) + setRunnerSkewMinimized(true) }, [haptic]) const onExpand = useCallback(() => { haptic.impact('light') - setRunnerSkewMinimized(false) setMinimized(false) + setRunnerSkewMinimized(false) }, [haptic]) const onTempDismiss = useCallback(() => { haptic.impact('light') - tempDismissRunnerSkew() setDismissed(true) + tempDismissRunnerSkew() }, [haptic]) const onRestart = useCallback(async (machine: Machine) => { @@ -162,7 +163,8 @@ export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: strin {skewed.map((machine) => { const host = machineDisplayHost(machine) const version = machine.metadata?.happyCliVersion - const canAutoHint = cliBinaryUpdatedOnDisk(machine.metadata) + const newerOnDisk = cliBinaryUpdatedOnDisk(machine.metadata) + const restartBusy = restartingId === machine.id return (
  • {host} {version ? ` · CLI ${version}` : null} - {canAutoHint ? ( + {newerOnDisk ? ( {t('runner.skew.banner.binaryUpdatedHint')} - ) : null} + ) : ( + + {t('runner.skew.banner.upgradeCliFirst')} + + )}
  • diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7199ccb616..cb37cf57f6 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -252,9 +252,12 @@ export default { 'runner.skew.banner.minimize': 'Minimize', 'runner.skew.banner.dismissTemp': 'Snooze 1h', 'runner.skew.banner.restart': 'Restart runner', + 'runner.skew.banner.restartUnavailable': 'Upgrade CLI first', + 'runner.skew.banner.restartNeedsNewerBinary': 'Restart only helps after a newer HAPI CLI is installed on that host. It does not download packages.', 'runner.skew.banner.restarting': 'Restarting…', - 'runner.skew.banner.binaryUpdatedHint': '(newer CLI on disk — restart may pick it up)', - 'runner.skew.banner.autoUpgradeHint': 'Opt-in auto-restart when a newer CLI is already on disk: set HAPI_AUTO_UPGRADE_RUNNERS=1 (or autoUpgradeRunners: true in hub settings.json). Off by default. Does not push packages to remote machines.', + 'runner.skew.banner.binaryUpdatedHint': '(newer CLI on disk — restart can load it)', + 'runner.skew.banner.upgradeCliFirst': '(no newer CLI on disk yet — install/upgrade on that host)', + 'runner.skew.banner.autoUpgradeHint': 'Opt-in is only “hub may stop-runner when a newer CLI is already installed on that machine.” It never downloads or installs packages remotely. Enable with HAPI_AUTO_UPGRADE_RUNNERS=1 or autoUpgradeRunners: true in hub settings.json (default off).', 'runner.skew.restartFailed': 'Failed to restart runner', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 96ffba93a9..7a914596d4 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -252,9 +252,12 @@ export default { 'runner.skew.banner.minimize': '最小化', 'runner.skew.banner.dismissTemp': '暂隐 1 小时', 'runner.skew.banner.restart': '重启运行器', + 'runner.skew.banner.restartUnavailable': '请先升级 CLI', + 'runner.skew.banner.restartNeedsNewerBinary': '仅当该主机已安装更新的 HAPI CLI 时,重启才有意义。不会远程下载或安装软件包。', 'runner.skew.banner.restarting': '正在重启…', - 'runner.skew.banner.binaryUpdatedHint': '(磁盘上已有更新 CLI — 重启可能加载)', - 'runner.skew.banner.autoUpgradeHint': '可选:磁盘上已有更新 CLI 时由 Hub 自动重启运行器。设置 HAPI_AUTO_UPGRADE_RUNNERS=1(或 settings.json 中 autoUpgradeRunners: true)。默认关闭。不会向远程机器推送安装包。', + 'runner.skew.banner.binaryUpdatedHint': '(磁盘上已有更新 CLI — 重启可加载)', + 'runner.skew.banner.upgradeCliFirst': '(磁盘尚无更新 CLI — 请先在该主机安装/升级)', + 'runner.skew.banner.autoUpgradeHint': '可选开关仅表示:当该机器磁盘上已有更新 CLI 时,Hub 可自动 stop-runner。绝不会远程下载或安装软件包。设置 HAPI_AUTO_UPGRADE_RUNNERS=1 或 settings.json 中 autoUpgradeRunners: true(默认关闭)。', 'runner.skew.restartFailed': '重启运行器失败', 'session.action.delete': '删除', 'session.action.copy': '复制', diff --git a/web/src/lib/runnerSkewBannerState.test.ts b/web/src/lib/runnerSkewBannerState.test.ts new file mode 100644 index 0000000000..b9c1153e5d --- /dev/null +++ b/web/src/lib/runnerSkewBannerState.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearRunnerSkewTempDismiss, + isRunnerSkewMinimized, + isRunnerSkewTempDismissed, + resetRunnerSkewBannerMemoryForTests, + setRunnerSkewMinimized, + tempDismissRunnerSkew, +} from './runnerSkewBannerState' + +describe('runnerSkewBannerState', () => { + beforeEach(() => { + window.sessionStorage.clear() + resetRunnerSkewBannerMemoryForTests() + }) + + afterEach(() => { + vi.restoreAllMocks() + window.sessionStorage.clear() + resetRunnerSkewBannerMemoryForTests() + }) + + it('persists minimize to sessionStorage', () => { + setRunnerSkewMinimized(true) + expect(isRunnerSkewMinimized()).toBe(true) + expect(window.sessionStorage.getItem('hapi.runnerSkew.minimized.v1')).toBe('1') + }) + + it('still minimizes when sessionStorage setItem throws QuotaExceededError', () => { + const proto = Object.getPrototypeOf(window.sessionStorage) as Storage + vi.spyOn(proto, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + expect(() => setRunnerSkewMinimized(true)).not.toThrow() + expect(isRunnerSkewMinimized()).toBe(true) + }) + + it('still temp-dismisses when sessionStorage is full', () => { + const proto = Object.getPrototypeOf(window.sessionStorage) as Storage + vi.spyOn(proto, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError') + }) + + const now = 1_700_000_000_000 + expect(() => tempDismissRunnerSkew(now)).not.toThrow() + expect(isRunnerSkewTempDismissed(now + 1)).toBe(true) + clearRunnerSkewTempDismiss() + expect(isRunnerSkewTempDismissed(now + 1)).toBe(false) + }) +}) diff --git a/web/src/lib/runnerSkewBannerState.ts b/web/src/lib/runnerSkewBannerState.ts index aba3770693..08c2225d87 100644 --- a/web/src/lib/runnerSkewBannerState.ts +++ b/web/src/lib/runnerSkewBannerState.ts @@ -2,6 +2,10 @@ const MINIMIZED_KEY = 'hapi.runnerSkew.minimized.v1' const DISMISS_UNTIL_KEY = 'hapi.runnerSkew.dismissUntil.v1' export const RUNNER_SKEW_TEMP_DISMISS_MS = 60 * 60_000 +/** In-memory fallback when sessionStorage is full / blocked (QuotaExceededError). */ +let memoryMinimized: boolean | null = null +let memoryDismissUntil: number | null = null + function readStorage(): Storage | null { if (typeof window === 'undefined') { return null @@ -13,29 +17,54 @@ function readStorage(): Storage | null { } } -export function isRunnerSkewMinimized(): boolean { - return readStorage()?.getItem(MINIMIZED_KEY) === '1' -} - -export function setRunnerSkewMinimized(minimized: boolean): void { +function writeStorage(mutate: (storage: Storage) => void): void { const storage = readStorage() if (!storage) { return } - if (minimized) { - storage.setItem(MINIMIZED_KEY, '1') - } else { - storage.removeItem(MINIMIZED_KEY) + try { + mutate(storage) + } catch { + // QuotaExceededError / SecurityError — keep memory fallback only. + } +} + +export function isRunnerSkewMinimized(): boolean { + if (memoryMinimized !== null) { + return memoryMinimized + } + try { + return readStorage()?.getItem(MINIMIZED_KEY) === '1' + } catch { + return false } } +export function setRunnerSkewMinimized(minimized: boolean): void { + memoryMinimized = minimized + writeStorage((storage) => { + if (minimized) { + storage.setItem(MINIMIZED_KEY, '1') + } else { + storage.removeItem(MINIMIZED_KEY) + } + }) +} + export function getRunnerSkewDismissUntil(): number { - const raw = readStorage()?.getItem(DISMISS_UNTIL_KEY) - if (!raw) { + if (memoryDismissUntil !== null) { + return memoryDismissUntil + } + try { + const raw = readStorage()?.getItem(DISMISS_UNTIL_KEY) + if (!raw) { + return 0 + } + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : 0 + } catch { return 0 } - const parsed = Number(raw) - return Number.isFinite(parsed) ? parsed : 0 } export function isRunnerSkewTempDismissed(now: number = Date.now()): boolean { @@ -43,9 +72,22 @@ export function isRunnerSkewTempDismissed(now: number = Date.now()): boolean { } export function tempDismissRunnerSkew(now: number = Date.now()): void { - readStorage()?.setItem(DISMISS_UNTIL_KEY, String(now + RUNNER_SKEW_TEMP_DISMISS_MS)) + const until = now + RUNNER_SKEW_TEMP_DISMISS_MS + memoryDismissUntil = until + writeStorage((storage) => { + storage.setItem(DISMISS_UNTIL_KEY, String(until)) + }) } export function clearRunnerSkewTempDismiss(): void { - readStorage()?.removeItem(DISMISS_UNTIL_KEY) + memoryDismissUntil = 0 + writeStorage((storage) => { + storage.removeItem(DISMISS_UNTIL_KEY) + }) +} + +/** Test helper: reset memory mirrors (sessionStorage cleared separately). */ +export function resetRunnerSkewBannerMemoryForTests(): void { + memoryMinimized = null + memoryDismissUntil = null } From bf120411b9fefc486dbe00e9f0fa6c969923d373 Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 18 Jul 2026 17:14:50 +0000 Subject: [PATCH 4/6] fix(hub): drop redundant autoUpgradeRunners; runners already self-restart CLI version handoff already reloads the runner when the on-disk binary mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the skew banner and manual Restart only as a stuck/disabled-handoff escape. Co-authored-by: Cursor --- docs/guide/installation.md | 14 +- hub/src/config/serverSettings.test.ts | 36 ----- hub/src/config/serverSettings.ts | 24 --- hub/src/config/settings.ts | 3 - hub/src/configuration.ts | 9 -- hub/src/startHub.ts | 7 +- hub/src/sync/runnerEnsure.test.ts | 144 +----------------- hub/src/sync/syncEngine.ts | 58 +------ .../components/RunnerVersionSkewBanner.tsx | 5 +- web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- 11 files changed, 15 insertions(+), 293 deletions(-) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 55d55f65fc..690d41c287 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -328,19 +328,7 @@ For running the hub and runner as persistent background services (pm2, launchd, ### Multi-machine hubs -You can run **one hub** and **runners on many machines** (each machine installs its own CLI). When you upgrade the hub, upgrade the HAPI CLI on every machine that parents sessions, then restart that machine's runner (`systemctl --user restart hapi-runner` or `hapi runner start`). Until a runner reports the capabilities the hub requires, the web UI shows a **Runner out of date** banner (minimizable / snoozeable) with the host name and upgrade steps. - -**Opt-in auto-restart (no remote package push):** if a machine already has a newer CLI binary on disk but is still running an old generation, you can let the hub call `stop-runner` so systemd/handoff loads it: - -```bash -# env (preferred for systemd units) -HAPI_AUTO_UPGRADE_RUNNERS=1 - -# or in ~/.hapi/settings.json -# "autoUpgradeRunners": true -``` - -Off by default. This does **not** download, `npm install`, or push packages to remotes — you still upgrade the CLI on each host yourself. Opt-in only means: when the hub detects skew **and** a newer on-disk binary (mtime differs from the running process), it may `stop-runner` so the existing install is loaded. The banner’s per-host **Restart runner** button is the same action, manual and only enabled when a newer binary is already on disk. +You can run **one hub** and **runners on many machines** (each machine installs its own CLI). When you upgrade the hub, upgrade the HAPI CLI on every machine that parents sessions. After the CLI binary on disk changes, that machine’s runner normally **self-restarts** via version handoff (unless `HAPI_DISABLE_VERSION_HANDOFF=1`). Until a runner reports the capabilities the hub requires, the web UI shows a **Runner out of date** banner (minimizable / snoozeable) with the host name and upgrade steps. The banner’s per-host **Restart** is only an escape hatch when handoff is stuck or disabled — the hub never downloads or installs packages on remotes. ## Security notes diff --git a/hub/src/config/serverSettings.test.ts b/hub/src/config/serverSettings.test.ts index 4d57ca2c76..8e33bd8782 100644 --- a/hub/src/config/serverSettings.test.ts +++ b/hub/src/config/serverSettings.test.ts @@ -28,40 +28,4 @@ describe('loadServerSettings', () => { await expect(loadServerSettings(dir)).rejects.toThrow('Unsupported old settings field') }) - - it('defaults autoUpgradeRunners to false (opt-in)', async () => { - dir = makeTempDir() - writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) - const previous = process.env.HAPI_AUTO_UPGRADE_RUNNERS - delete process.env.HAPI_AUTO_UPGRADE_RUNNERS - try { - const result = await loadServerSettings(dir) - expect(result.settings.autoUpgradeRunners).toBe(false) - expect(result.sources.autoUpgradeRunners).toBe('default') - } finally { - if (previous === undefined) { - delete process.env.HAPI_AUTO_UPGRADE_RUNNERS - } else { - process.env.HAPI_AUTO_UPGRADE_RUNNERS = previous - } - } - }) - - it('enables autoUpgradeRunners from env', async () => { - dir = makeTempDir() - writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) - const previous = process.env.HAPI_AUTO_UPGRADE_RUNNERS - process.env.HAPI_AUTO_UPGRADE_RUNNERS = '1' - try { - const result = await loadServerSettings(dir) - expect(result.settings.autoUpgradeRunners).toBe(true) - expect(result.sources.autoUpgradeRunners).toBe('env') - } finally { - if (previous === undefined) { - delete process.env.HAPI_AUTO_UPGRADE_RUNNERS - } else { - process.env.HAPI_AUTO_UPGRADE_RUNNERS = previous - } - } - }) }) diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index 549a2dd329..a422be83d4 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -21,12 +21,6 @@ export interface ServerSettings { listenPort: number publicUrl: string corsOrigins: string[] - /** - * Opt-in: when a skewed runner already has a newer CLI binary on disk, - * hub may call stop-runner so systemd/handoff loads it. Default false. - * Env: HAPI_AUTO_UPGRADE_RUNNERS=true|1 - */ - autoUpgradeRunners: boolean } export interface ServerSettingsResult { @@ -40,7 +34,6 @@ export interface ServerSettingsResult { listenPort: 'env' | 'file' | 'default' publicUrl: 'env' | 'file' | 'default' corsOrigins: 'env' | 'file' | 'default' - autoUpgradeRunners: 'env' | 'file' | 'default' } savedToFile: boolean } @@ -111,7 +104,6 @@ export async function loadServerSettings(dataDir: string): Promise file > null let telegramBotToken: string | null = null @@ -231,21 +223,6 @@ export async function loadServerSettings(dataDir: string): Promise file > false (opt-in) - let autoUpgradeRunners = false - if (process.env.HAPI_AUTO_UPGRADE_RUNNERS !== undefined) { - autoUpgradeRunners = process.env.HAPI_AUTO_UPGRADE_RUNNERS === 'true' - || process.env.HAPI_AUTO_UPGRADE_RUNNERS === '1' - sources.autoUpgradeRunners = 'env' - if (settings.autoUpgradeRunners === undefined) { - settings.autoUpgradeRunners = autoUpgradeRunners - needsSave = true - } - } else if (typeof settings.autoUpgradeRunners === 'boolean') { - autoUpgradeRunners = settings.autoUpgradeRunners - sources.autoUpgradeRunners = 'file' - } - return { settings, write: needsSave, @@ -259,7 +236,6 @@ export async function loadServerSettings(dataDir: string): Promise> - - /** Opt-in: hub stop-runner when skewed + newer CLI on disk. */ - autoUpgradeRunners?: boolean } export function getSettingsFile(dataDir: string): string { diff --git a/hub/src/configuration.ts b/hub/src/configuration.ts index 13d8493a6e..79dca35db7 100644 --- a/hub/src/configuration.ts +++ b/hub/src/configuration.ts @@ -21,7 +21,6 @@ * - VAPID_SUBJECT: Contact email or URL for Web Push (defaults to mailto:admin@hapi.run) * - HAPI_HOME: Data directory (default: ~/.hapi) * - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db) - * - HAPI_AUTO_UPGRADE_RUNNERS: Opt-in stop-runner when skewed + newer CLI on disk (true|1) */ import { existsSync, mkdirSync } from 'node:fs' @@ -43,7 +42,6 @@ export interface ConfigSources { listenPort: ConfigSource publicUrl: ConfigSource corsOrigins: ConfigSource - autoUpgradeRunners: ConfigSource cliApiToken: 'env' | 'file' | 'generated' } @@ -93,12 +91,6 @@ class Configuration { /** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */ public readonly corsOrigins: string[] - /** - * Opt-in: hub may stop-runner when a skewed machine already has a newer - * CLI binary on disk. Default false. - */ - public readonly autoUpgradeRunners: boolean - /** Sources of each configuration value */ public readonly sources: ConfigSources @@ -123,7 +115,6 @@ class Configuration { this.listenPort = serverSettings.listenPort this.publicUrl = serverSettings.publicUrl this.corsOrigins = serverSettings.corsOrigins - this.autoUpgradeRunners = serverSettings.autoUpgradeRunners // CLI API token - will be set by _setCliApiToken() before create() returns this.cliApiToken = '' diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index ce83dfd7ee..3d5a75879c 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -199,12 +199,7 @@ export async function startHub(options: StartHubOptions = {}): Promise syncEngine?.clearQueuedThinkingGrace(sessionId) }) - syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager, { - autoUpgradeRunners: config.autoUpgradeRunners, - }) - if (config.autoUpgradeRunners) { - console.log('[Hub] autoUpgradeRunners enabled (opt-in): will stop-runner when skewed + newer CLI on disk') - } + syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager) // Accountable principal for A2A work-graph notify ingest (P3). syncEngine.setHubOwnerUserId(await getOrCreateOwnerId()) diff --git a/hub/src/sync/runnerEnsure.test.ts b/hub/src/sync/runnerEnsure.test.ts index cd542839f1..3dbc17af65 100644 --- a/hub/src/sync/runnerEnsure.test.ts +++ b/hub/src/sync/runnerEnsure.test.ts @@ -2,149 +2,9 @@ import { describe, expect, it, mock } from 'bun:test' import { Store } from '../store' import { RpcRegistry } from '../socket/rpcRegistry' import { SyncEngine } from './syncEngine' -import { CURRENT_MACHINE_CAPABILITIES } from '@hapi/protocol/runnerCapabilities' -describe('SyncEngine runner generation ensure', () => { - it('does not auto stop-runner when autoUpgradeRunners is off (default)', async () => { - const store = new Store(':memory:') - const engine = new SyncEngine( - store, - {} as never, - new RpcRegistry(), - { broadcast() {} } as never - ) - - try { - const stopRunner = mock(async () => undefined) - ;(engine as any).rpcGateway.stopRunner = stopRunner - - engine.getOrCreateMachine( - 'stale-runner', - { - host: 'proxmox', - platform: 'linux', - happyCliVersion: '0.20.0', - capabilities: [], - startedCliMtimeMs: 100, - installedCliMtimeMs: 200, - }, - null, - 'default' - ) - engine.handleMachineAlive({ machineId: 'stale-runner', time: Date.now() }) - await new Promise((r) => setTimeout(r, 20)) - expect(stopRunner).not.toHaveBeenCalled() - } finally { - engine.stop() - } - }) - - it('calls stop-runner when autoUpgradeRunners is on and on-disk CLI mtime differs', async () => { - const store = new Store(':memory:') - const engine = new SyncEngine( - store, - {} as never, - new RpcRegistry(), - { broadcast() {} } as never, - { autoUpgradeRunners: true } - ) - - try { - const stopRunner = mock(async () => undefined) - ;(engine as any).rpcGateway.stopRunner = stopRunner - - engine.getOrCreateMachine( - 'stale-runner', - { - host: 'proxmox', - platform: 'linux', - happyCliVersion: '0.20.0', - capabilities: [], - startedCliMtimeMs: 100, - installedCliMtimeMs: 200, - }, - null, - 'default' - ) - engine.handleMachineAlive({ machineId: 'stale-runner', time: Date.now() }) - - await new Promise((r) => setTimeout(r, 20)) - expect(stopRunner).toHaveBeenCalledWith('stale-runner') - } finally { - engine.stop() - } - }) - - it('does not call stop-runner when skewed but binary mtime is unchanged', async () => { - const store = new Store(':memory:') - const engine = new SyncEngine( - store, - {} as never, - new RpcRegistry(), - { broadcast() {} } as never, - { autoUpgradeRunners: true } - ) - - try { - const stopRunner = mock(async () => undefined) - ;(engine as any).rpcGateway.stopRunner = stopRunner - - engine.getOrCreateMachine( - 'old-runner', - { - host: 'proxmox', - platform: 'linux', - happyCliVersion: '0.20.0', - startedCliMtimeMs: 100, - installedCliMtimeMs: 100, - }, - null, - 'default' - ) - engine.handleMachineAlive({ machineId: 'old-runner', time: Date.now() }) - await new Promise((r) => setTimeout(r, 20)) - expect(stopRunner).not.toHaveBeenCalled() - } finally { - engine.stop() - } - }) - - it('does not call stop-runner when required capabilities are present', async () => { - const store = new Store(':memory:') - const engine = new SyncEngine( - store, - {} as never, - new RpcRegistry(), - { broadcast() {} } as never, - { autoUpgradeRunners: true } - ) - - try { - const stopRunner = mock(async () => undefined) - ;(engine as any).rpcGateway.stopRunner = stopRunner - - engine.getOrCreateMachine( - 'current-runner', - { - host: 'oos', - platform: 'linux', - happyCliVersion: '0.23.0', - capabilities: [...CURRENT_MACHINE_CAPABILITIES], - startedCliMtimeMs: 100, - installedCliMtimeMs: 200, - }, - null, - 'default' - ) - engine.handleMachineAlive({ machineId: 'current-runner', time: Date.now() }) - await new Promise((r) => setTimeout(r, 20)) - expect(stopRunner).not.toHaveBeenCalled() - } finally { - engine.stop() - } - }) - - it('restartMachineRunner always stop-runners without requiring autoUpgradeRunners', async () => { +describe('SyncEngine restartMachineRunner', () => { + it('stop-runners for an online machine (manual banner escape hatch)', async () => { const store = new Store(':memory:') const engine = new SyncEngine( store, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 3ddfb1367c..bf0a94ff7b 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -165,11 +165,6 @@ function extractClaudeUserMessageTextFromAgentOutput(content: unknown): string | return extractUserMessageText(message.content) } -export type SyncEngineOptions = { - /** Opt-in auto stop-runner when skewed runner has newer CLI on disk. Default false. */ - autoUpgradeRunners?: boolean -} - export class SyncEngine { private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache @@ -200,19 +195,13 @@ export class SyncEngine { * Defaults to "1" for unit tests; startHub overwrites with getOrCreateOwnerId(). */ private hubOwnerUserId: string = '1' - /** Rate-limit hub-initiated stop-runner ensure attempts (machineId → last attempt ms). */ - private readonly runnerEnsureAttemptAt = new Map() - private static readonly RUNNER_ENSURE_COOLDOWN_MS = 5 * 60_000 - private readonly autoUpgradeRunners: boolean constructor( private readonly store: Store, private readonly io: Server, rpcRegistry: RpcRegistry, sseManager: SSEManager, - options?: SyncEngineOptions ) { - this.autoUpgradeRunners = options?.autoUpgradeRunners === true this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) this.machineCache = new MachineCache(store, this.eventPublisher) @@ -478,7 +467,6 @@ export class SyncEngine { if (event.type === 'machine-updated' && event.machineId) { this.machineCache.refreshMachine(event.machineId) - void this.maybeEnsureRunnerGeneration(event.machineId) return } @@ -789,7 +777,7 @@ export class SyncEngine { } } -async uploadScratchlistAttachment( + async uploadScratchlistAttachment( sessionId: string, namespace: string, filename: string, @@ -888,50 +876,12 @@ async uploadScratchlistAttachment( handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void { this.machineCache.handleMachineAlive(payload) - void this.maybeEnsureRunnerGeneration(payload.machineId) - } - - /** - * When opt-in auto-upgrade is enabled and a connected runner is missing - * required capabilities but a newer CLI binary is already on disk, ask it - * to stop so systemd/handoff loads the new generation. Otherwise the web - * skew banner stays until the operator upgrades/restarts manually. - */ - private async maybeEnsureRunnerGeneration(machineId: string): Promise { - if (!this.autoUpgradeRunners) { - return - } - const machine = this.machineCache.getMachine(machineId) - if (!machine?.active || !machine.metadata) { - return - } - if (!isMachineCapabilitySkewed(machine.metadata.capabilities)) { - return - } - if (!cliBinaryUpdatedOnDisk(machine.metadata)) { - return - } - const lastAttempt = this.runnerEnsureAttemptAt.get(machineId) ?? 0 - if (Date.now() - lastAttempt < SyncEngine.RUNNER_ENSURE_COOLDOWN_MS) { - return - } - this.runnerEnsureAttemptAt.set(machineId, Date.now()) - try { - console.warn('[runner-ensure] Stopping skewed runner so a newer on-disk CLI can load', { - machineId, - host: machine.metadata.host, - version: machine.metadata.happyCliVersion, - }) - await this.rpcGateway.stopRunner(machineId) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.warn('[runner-ensure] stop-runner failed', { machineId, message }) - } } /** - * Operator-initiated restart (banner Restart button). Always attempts - * stop-runner for an online machine — does not require autoUpgradeRunners. + * Manual stop-runner (banner Restart). Normally unnecessary: runners already + * self-restart on CLI mtime drift via version handoff. Use when handoff is + * disabled (HAPI_DISABLE_VERSION_HANDOFF=1) or stuck. */ async restartMachineRunner(machineId: string, namespace: string): Promise< | { type: 'success'; message: string } diff --git a/web/src/components/RunnerVersionSkewBanner.tsx b/web/src/components/RunnerVersionSkewBanner.tsx index a40f13026e..8be28be93e 100644 --- a/web/src/components/RunnerVersionSkewBanner.tsx +++ b/web/src/components/RunnerVersionSkewBanner.tsx @@ -31,7 +31,8 @@ export function listSkewedMachines(machines: Machine[]): Machine[] { /** * Compact, minimizable skew banner (#1084 dogfood). * Temp-dismiss (1h, sessionStorage) or minimize so sessions stay clickable. - * Manual Restart asks hub to stop-runner; auto-upgrade is opt-in on the hub. + * Manual Restart asks hub to stop-runner (escape hatch when version handoff + * is stuck or HAPI_DISABLE_VERSION_HANDOFF=1). Normal upgrades self-restart. */ export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: string } = {}) { const { api } = useAppContext() @@ -212,7 +213,7 @@ export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: strin ) : null}

    - {t('runner.skew.banner.autoUpgradeHint')} + {t('runner.skew.banner.handoffHint')}

    ) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index cb37cf57f6..bb7742b409 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -255,9 +255,9 @@ export default { 'runner.skew.banner.restartUnavailable': 'Upgrade CLI first', 'runner.skew.banner.restartNeedsNewerBinary': 'Restart only helps after a newer HAPI CLI is installed on that host. It does not download packages.', 'runner.skew.banner.restarting': 'Restarting…', - 'runner.skew.banner.binaryUpdatedHint': '(newer CLI on disk — restart can load it)', + 'runner.skew.banner.binaryUpdatedHint': '(newer CLI on disk — runner should self-restart soon)', 'runner.skew.banner.upgradeCliFirst': '(no newer CLI on disk yet — install/upgrade on that host)', - 'runner.skew.banner.autoUpgradeHint': 'Opt-in is only “hub may stop-runner when a newer CLI is already installed on that machine.” It never downloads or installs packages remotely. Enable with HAPI_AUTO_UPGRADE_RUNNERS=1 or autoUpgradeRunners: true in hub settings.json (default off).', + 'runner.skew.banner.handoffHint': 'After you upgrade the CLI on a host, that runner normally self-restarts (version handoff). Restart here is only needed if handoff is stuck or disabled (HAPI_DISABLE_VERSION_HANDOFF=1). Hub never downloads packages to remotes.', 'runner.skew.restartFailed': 'Failed to restart runner', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 7a914596d4..0a40be411b 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -255,9 +255,9 @@ export default { 'runner.skew.banner.restartUnavailable': '请先升级 CLI', 'runner.skew.banner.restartNeedsNewerBinary': '仅当该主机已安装更新的 HAPI CLI 时,重启才有意义。不会远程下载或安装软件包。', 'runner.skew.banner.restarting': '正在重启…', - 'runner.skew.banner.binaryUpdatedHint': '(磁盘上已有更新 CLI — 重启可加载)', + 'runner.skew.banner.binaryUpdatedHint': '(磁盘上已有更新 CLI — 运行器应很快自行重启)', 'runner.skew.banner.upgradeCliFirst': '(磁盘尚无更新 CLI — 请先在该主机安装/升级)', - 'runner.skew.banner.autoUpgradeHint': '可选开关仅表示:当该机器磁盘上已有更新 CLI 时,Hub 可自动 stop-runner。绝不会远程下载或安装软件包。设置 HAPI_AUTO_UPGRADE_RUNNERS=1 或 settings.json 中 autoUpgradeRunners: true(默认关闭)。', + 'runner.skew.banner.handoffHint': '在主机上升级 CLI 后,运行器通常会自行重启(version handoff)。此处“重启”仅在 handoff 卡住或已禁用(HAPI_DISABLE_VERSION_HANDOFF=1)时需要。Hub 不会向远程机器下载或安装软件包。', 'runner.skew.restartFailed': '重启运行器失败', 'session.action.delete': '删除', 'session.action.copy': '复制', From b5b0e428daf69d28eafdbbad51ed2b2aee7aeee2 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:06:23 +0000 Subject: [PATCH 5/6] fix(cli,hub,web): runner-only caps ads; gate Restart on supervisor Address #1108 bot Majors on the thin tip: terminal/lazy bootstraps no longer merge CURRENT_MACHINE_CAPABILITIES into the machine row (only asRunner registration does). Banner Restart refuses unsupervised hosts so stop-runner cannot leave a detached laptop offline; supervised runners advertise supervisedRestart via HAPI_RUNNER_SUPERVISED=1. Co-authored-by: Cursor --- cli/src/agent/sessionFactory.test.ts | 33 ++++++++++++- cli/src/agent/sessionFactory.ts | 18 ++++++- cli/src/runner/run.ts | 6 ++- hub/src/sync/runnerEnsure.test.ts | 48 +++++++++++++++++-- hub/src/sync/syncEngine.ts | 17 +++++-- shared/src/schemas.ts | 5 ++ .../RunnerVersionSkewBanner.test.tsx | 31 +++++++++++- .../components/RunnerVersionSkewBanner.tsx | 13 +++-- web/src/lib/locales/en.ts | 3 +- web/src/lib/locales/zh-CN.ts | 3 +- 10 files changed, 158 insertions(+), 19 deletions(-) diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 2460e76501..1fe8b2895c 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Session } from '@/api/types' const { @@ -56,6 +56,7 @@ import { bootstrapExistingSession, bootstrapLazySession, bootstrapSession, + buildMachineMetadata, buildSessionMetadata } from './sessionFactory' @@ -364,3 +365,33 @@ describe('bootstrapSession HAPI_SESSION_ID export', () => { expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hub-session-42') }) }) + +describe('buildMachineMetadata runner-only capabilities', () => { + const originalSupervised = process.env.HAPI_RUNNER_SUPERVISED + + afterEach(() => { + if (originalSupervised === undefined) { + delete process.env.HAPI_RUNNER_SUPERVISED + } else { + process.env.HAPI_RUNNER_SUPERVISED = originalSupervised + } + }) + + it('omits machine RPC capabilities for terminal bootstrap metadata', () => { + delete process.env.HAPI_RUNNER_SUPERVISED + const metadata = buildMachineMetadata() + expect(metadata.capabilities).toBeUndefined() + expect(metadata.startedCliMtimeMs).toBeUndefined() + expect(metadata.installedCliMtimeMs).toBeUndefined() + expect(metadata.supervisedRestart).toBeUndefined() + }) + + it('advertises capabilities and supervisedRestart only for asRunner', () => { + process.env.HAPI_RUNNER_SUPERVISED = '1' + const metadata = buildMachineMetadata({ asRunner: true, startedCliMtimeMs: 42 }) + expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner'])) + expect(metadata.startedCliMtimeMs).toBe(42) + expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000) + expect(metadata.supervisedRestart).toBe(true) + }) +}) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index 4d9c88afd2..eb2eb898a1 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -45,10 +45,17 @@ export type SessionBootstrapResult = { export function buildMachineMetadata(options?: { workspaceRoots?: string[] startedCliMtimeMs?: number + /** + * Only the long-lived runner daemon may advertise machine RPC capabilities + * and CLI mtimes. Terminal/lazy/existing session bootstraps must omit this + * so a newer CLI session cannot paint an old connected runner as current + * (#1108 bot Major). + */ + asRunner?: boolean }): MachineMetadata { const installedCliMtimeMs = getInstalledCliMtimeMs() const startedCliMtimeMs = options?.startedCliMtimeMs ?? installedCliMtimeMs - return { + const base: MachineMetadata = { host: process.env.HAPI_HOSTNAME || os.hostname(), platform: os.platform(), happyCliVersion: packageJson.version, @@ -56,9 +63,18 @@ export function buildMachineMetadata(options?: { happyHomeDir: configuration.happyHomeDir, happyLibDir: runtimePath(), workspaceRoots: options?.workspaceRoots, + } + if (!options?.asRunner) { + return base + } + return { + ...base, capabilities: [...CURRENT_MACHINE_CAPABILITIES], ...(typeof startedCliMtimeMs === 'number' ? { startedCliMtimeMs } : {}), ...(typeof installedCliMtimeMs === 'number' ? { installedCliMtimeMs } : {}), + // systemd/pm2 hosts set HAPI_RUNNER_SUPERVISED=1 — banner Restart may + // stop-runner knowing the supervisor cold-starts the new binary. + ...(process.env.HAPI_RUNNER_SUPERVISED === '1' ? { supervisedRestart: true } : {}), } } diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 9c91b5cfb3..60b76d450a 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1121,7 +1121,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): const machine = await withRetry( () => api.getOrCreateMachine({ machineId, - metadata: buildMachineMetadata({ workspaceRoots, startedCliMtimeMs: startedWithCliMtimeMs }), + metadata: buildMachineMetadata({ + workspaceRoots, + startedCliMtimeMs: startedWithCliMtimeMs, + asRunner: true, + }), runnerState: initialRunnerState }), { diff --git a/hub/src/sync/runnerEnsure.test.ts b/hub/src/sync/runnerEnsure.test.ts index 3dbc17af65..02742ee3d2 100644 --- a/hub/src/sync/runnerEnsure.test.ts +++ b/hub/src/sync/runnerEnsure.test.ts @@ -4,7 +4,7 @@ import { RpcRegistry } from '../socket/rpcRegistry' import { SyncEngine } from './syncEngine' describe('SyncEngine restartMachineRunner', () => { - it('stop-runners for an online machine (manual banner escape hatch)', async () => { + it('refuses Restart on unsupervised hosts (stop would leave runner offline)', async () => { const store = new Store(':memory:') const engine = new SyncEngine( store, @@ -19,15 +19,55 @@ describe('SyncEngine restartMachineRunner', () => { engine.getOrCreateMachine( 'manual-runner', - { host: 'proxmox', platform: 'linux', happyCliVersion: '0.20.0' }, + { host: 'laptop', platform: 'linux', happyCliVersion: '0.20.0' }, null, 'default' ) engine.handleMachineAlive({ machineId: 'manual-runner', time: Date.now() }) const result = await engine.restartMachineRunner('manual-runner', 'default') - expect(result).toEqual({ type: 'success', message: 'Runner restart requested' }) - expect(stopRunner).toHaveBeenCalledWith('manual-runner') + expect(result.type).toBe('error') + if (result.type === 'error') { + expect(result.code).toBe('restart_unsupported') + } + expect(stopRunner).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) + + it('stop-runners for a supervised online machine (banner escape hatch)', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const stopRunner = mock(async () => undefined) + ;(engine as any).rpcGateway.stopRunner = stopRunner + + engine.getOrCreateMachine( + 'supervised-runner', + { + host: 'proxmox', + platform: 'linux', + happyCliVersion: '0.20.0', + supervisedRestart: true, + }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'supervised-runner', time: Date.now() }) + + const result = await engine.restartMachineRunner('supervised-runner', 'default') + expect(result).toEqual({ + type: 'success', + message: 'Runner stop requested; supervisor will relaunch', + }) + expect(stopRunner).toHaveBeenCalledWith('supervised-runner') } finally { engine.stop() } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index bf0a94ff7b..d74ab078e4 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -879,13 +879,13 @@ export class SyncEngine { } /** - * Manual stop-runner (banner Restart). Normally unnecessary: runners already - * self-restart on CLI mtime drift via version handoff. Use when handoff is - * disabled (HAPI_DISABLE_VERSION_HANDOFF=1) or stuck. + * Manual stop-runner for supervised hosts only (banner Restart). + * Detached `hapi runner start` has no supervisor — stop would leave the + * host offline. Require `metadata.supervisedRestart` (HAPI_RUNNER_SUPERVISED=1). */ async restartMachineRunner(machineId: string, namespace: string): Promise< | { type: 'success'; message: string } - | { type: 'error'; message: string; code: 'machine_not_found' | 'machine_offline' | 'restart_failed' } + | { type: 'error'; message: string; code: 'machine_not_found' | 'machine_offline' | 'restart_unsupported' | 'restart_failed' } > { const machine = this.machineCache.getMachineByNamespace(machineId, namespace) ?? this.machineCache.refreshMachine(machineId) @@ -895,9 +895,16 @@ export class SyncEngine { if (!machine.active) { return { type: 'error', message: 'Machine is offline', code: 'machine_offline' } } + if (machine.metadata?.supervisedRestart !== true) { + return { + type: 'error', + message: 'Restart requires a supervised runner (HAPI_RUNNER_SUPERVISED=1); unsupervised stop would leave the host offline', + code: 'restart_unsupported', + } + } try { await this.rpcGateway.stopRunner(machineId) - return { type: 'success', message: 'Runner restart requested' } + return { type: 'success', message: 'Runner stop requested; supervisor will relaunch' } } catch (error) { return { type: 'error', diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f72aca7707..7c7d3215ea 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -439,6 +439,11 @@ export const MachineMetadataSchema = z.object({ startedCliMtimeMs: z.number().optional(), /** Current on-disk CLI binary/package mtime (may differ after upgrade). */ installedCliMtimeMs: z.number().optional(), + /** + * Runner is under systemd/pm2 (HAPI_RUNNER_SUPERVISED=1). Banner Restart + * may stop-runner; unsupervised detached runners must not use that path. + */ + supervisedRestart: z.boolean().optional(), }) export type MachineMetadata = z.infer diff --git a/web/src/components/RunnerVersionSkewBanner.test.tsx b/web/src/components/RunnerVersionSkewBanner.test.tsx index b5be416384..33e0041a01 100644 --- a/web/src/components/RunnerVersionSkewBanner.test.tsx +++ b/web/src/components/RunnerVersionSkewBanner.test.tsx @@ -192,7 +192,34 @@ describe('RunnerVersionSkewBanner', () => { expect(restart).toHaveTextContent(/Upgrade CLI first/) }) - it('calls restartMachineRunner when Restart is clicked and newer CLI is on disk', async () => { + it('disables Restart when newer CLI is on disk but runner is unsupervised', () => { + useMachinesMock.mockReturnValue({ + machines: [ + makeMachine({ + id: 'old', + metadata: { + host: 'laptop', + platform: 'linux', + happyCliVersion: '0.20.0', + startedCliMtimeMs: 100, + installedCliMtimeMs: 200, + }, + }), + ], + isLoading: false, + error: null, + }) + + render( + + + , + ) + + expect(screen.getByTestId('runner-version-skew-restart-old')).toBeDisabled() + }) + + it('calls restartMachineRunner when Restart is clicked on a supervised host with newer CLI', async () => { useMachinesMock.mockReturnValue({ machines: [ makeMachine({ @@ -203,6 +230,7 @@ describe('RunnerVersionSkewBanner', () => { happyCliVersion: '0.20.0', startedCliMtimeMs: 100, installedCliMtimeMs: 200, + supervisedRestart: true, }, }), ], @@ -221,7 +249,6 @@ describe('RunnerVersionSkewBanner', () => { expect(restartMachineRunnerMock).toHaveBeenCalledWith('old') }) }) - it('minimizes even when sessionStorage setItem throws QuotaExceededError', () => { const proto = Object.getPrototypeOf(window.sessionStorage) as Storage vi.spyOn(proto, 'setItem').mockImplementation(() => { diff --git a/web/src/components/RunnerVersionSkewBanner.tsx b/web/src/components/RunnerVersionSkewBanner.tsx index 8be28be93e..7edd76be1d 100644 --- a/web/src/components/RunnerVersionSkewBanner.tsx +++ b/web/src/components/RunnerVersionSkewBanner.tsx @@ -165,7 +165,14 @@ export function RunnerVersionSkewBanner({ topClassName }: { topClassName?: strin const host = machineDisplayHost(machine) const version = machine.metadata?.happyCliVersion const newerOnDisk = cliBinaryUpdatedOnDisk(machine.metadata) + const supervised = machine.metadata?.supervisedRestart === true + const canRestart = newerOnDisk && supervised const restartBusy = restartingId === machine.id + const restartTitle = !newerOnDisk + ? t('runner.skew.banner.restartNeedsNewerBinary') + : !supervised + ? t('runner.skew.banner.restartNeedsSupervisor') + : undefined return (
  • void onRestart(machine)} className="shrink-0 rounded bg-amber-900 px-2 py-1 text-xs font-medium text-amber-50 disabled:opacity-50 dark:bg-amber-100 dark:text-amber-950" > {restartBusy ? t('runner.skew.banner.restarting') - : newerOnDisk + : canRestart ? t('runner.skew.banner.restart') : t('runner.skew.banner.restartUnavailable')} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index bb7742b409..8393bcb7bc 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -254,10 +254,11 @@ export default { 'runner.skew.banner.restart': 'Restart runner', 'runner.skew.banner.restartUnavailable': 'Upgrade CLI first', 'runner.skew.banner.restartNeedsNewerBinary': 'Restart only helps after a newer HAPI CLI is installed on that host. It does not download packages.', + 'runner.skew.banner.restartNeedsSupervisor': 'Restart only works when the runner is supervised (HAPI_RUNNER_SUPERVISED=1 / systemd). Unsupervised stop would leave the host offline.', 'runner.skew.banner.restarting': 'Restarting…', 'runner.skew.banner.binaryUpdatedHint': '(newer CLI on disk — runner should self-restart soon)', 'runner.skew.banner.upgradeCliFirst': '(no newer CLI on disk yet — install/upgrade on that host)', - 'runner.skew.banner.handoffHint': 'After you upgrade the CLI on a host, that runner normally self-restarts (version handoff). Restart here is only needed if handoff is stuck or disabled (HAPI_DISABLE_VERSION_HANDOFF=1). Hub never downloads packages to remotes.', + 'runner.skew.banner.handoffHint': 'After you upgrade the CLI on a host, that runner normally self-restarts (version handoff). Restart here only stops a supervised runner so systemd/pm2 relaunches it — never on a detached laptop runner.', 'runner.skew.restartFailed': 'Failed to restart runner', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 0a40be411b..f93ea81a55 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -254,10 +254,11 @@ export default { 'runner.skew.banner.restart': '重启运行器', 'runner.skew.banner.restartUnavailable': '请先升级 CLI', 'runner.skew.banner.restartNeedsNewerBinary': '仅当该主机已安装更新的 HAPI CLI 时,重启才有意义。不会远程下载或安装软件包。', + 'runner.skew.banner.restartNeedsSupervisor': '仅在受监督运行器上可用(HAPI_RUNNER_SUPERVISED=1 / systemd)。无监督 stop 会让主机离线。', 'runner.skew.banner.restarting': '正在重启…', 'runner.skew.banner.binaryUpdatedHint': '(磁盘上已有更新 CLI — 运行器应很快自行重启)', 'runner.skew.banner.upgradeCliFirst': '(磁盘尚无更新 CLI — 请先在该主机安装/升级)', - 'runner.skew.banner.handoffHint': '在主机上升级 CLI 后,运行器通常会自行重启(version handoff)。此处“重启”仅在 handoff 卡住或已禁用(HAPI_DISABLE_VERSION_HANDOFF=1)时需要。Hub 不会向远程机器下载或安装软件包。', + 'runner.skew.banner.handoffHint': '在主机上升级 CLI 后,运行器通常会自行重启(version handoff)。此处“重启”仅用于受监督运行器(systemd/pm2 会重新拉起),切勿在无监督笔记本运行器上使用。', 'runner.skew.restartFailed': '重启运行器失败', 'session.action.delete': '删除', 'session.action.copy': '复制', From 99568368f4616ba694ed1a5f2e83aec684d298da Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:06:20 +0000 Subject: [PATCH 6/6] fix(hub,cli,web): clear sticky runner ads; docs SUPERVISED; i18n skew label Omit-means-clear on runner registration so rollback cannot leave supervisedRestart/capabilities sticky; always advertise boolean supervisedRestart from asRunner. Document HAPI_RUNNER_SUPERVISED=1 and localize MachineSelector UPDATE REQUIRED. Co-authored-by: Cursor --- cli/src/agent/sessionFactory.test.ts | 7 ++ cli/src/agent/sessionFactory.ts | 5 +- docs/guide/deployment.md | 12 ++- docs/guide/installation.md | 2 +- hub/src/store/machines.test.ts | 79 +++++++++++++++++++ hub/src/store/machines.ts | 33 +++++++- .../components/NewSession/MachineSelector.tsx | 6 +- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 9 files changed, 135 insertions(+), 11 deletions(-) diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 1fe8b2895c..fb50342e75 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -394,4 +394,11 @@ describe('buildMachineMetadata runner-only capabilities', () => { expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000) expect(metadata.supervisedRestart).toBe(true) }) + + it('always sends supervisedRestart boolean for asRunner so sticky true can clear', () => { + delete process.env.HAPI_RUNNER_SUPERVISED + const metadata = buildMachineMetadata({ asRunner: true }) + expect(metadata.capabilities).toEqual(expect.arrayContaining(['stop-runner'])) + expect(metadata.supervisedRestart).toBe(false) + }) }) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index eb2eb898a1..e42e767abc 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -72,9 +72,8 @@ export function buildMachineMetadata(options?: { capabilities: [...CURRENT_MACHINE_CAPABILITIES], ...(typeof startedCliMtimeMs === 'number' ? { startedCliMtimeMs } : {}), ...(typeof installedCliMtimeMs === 'number' ? { installedCliMtimeMs } : {}), - // systemd/pm2 hosts set HAPI_RUNNER_SUPERVISED=1 — banner Restart may - // stop-runner knowing the supervisor cold-starts the new binary. - ...(process.env.HAPI_RUNNER_SUPERVISED === '1' ? { supervisedRestart: true } : {}), + // Always boolean so hub merge can clear a prior true on unsupervised restart. + supervisedRestart: process.env.HAPI_RUNNER_SUPERVISED === '1', } } diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 91a17073fd..3eb470976c 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -136,7 +136,9 @@ npm install -g pm2 # Start hub and runner pm2 start "hapi hub --relay" --name hapi-hub -pm2 start "hapi runner start-sync" --name hapi-runner +# HAPI_RUNNER_SUPERVISED=1 lets the web Restart button stop the runner knowing +# pm2 will cold-start it again (unsupervised stop would leave the host offline). +HAPI_RUNNER_SUPERVISED=1 pm2 start "hapi runner start-sync" --name hapi-runner # View status and logs pm2 status @@ -196,6 +198,11 @@ Create plist files for automatic startup on macOS. runner start-sync + EnvironmentVariables + + HAPI_RUNNER_SUPERVISED + 1 + RunAtLoad KeepAlive @@ -259,6 +266,9 @@ After=network.target hapi-hub.service [Service] Type=simple KillMode=process +# Advertise supervisedRestart so the web UI Restart button may stop-runner +# knowing systemd will cold-start the unit again. +Environment=HAPI_RUNNER_SUPERVISED=1 ExecStart=/usr/local/bin/hapi runner start-sync Restart=always RestartSec=5 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 690d41c287..62c48ac8f5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -324,7 +324,7 @@ Use `--workspace-root ` to restrict which directories the runner can brows hapi runner start --workspace-root ~/projects --workspace-root ~/work ``` -For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). +For running the hub and runner as persistent background services (pm2, launchd, systemd), see [Deployment](./deployment.md). Supervised installs should set `HAPI_RUNNER_SUPERVISED=1` on the runner process (systemd `Environment=` / pm2 `--env`) so the web **Restart** control can safely stop-runner knowing the supervisor will cold-start it. ### Multi-machine hubs diff --git a/hub/src/store/machines.test.ts b/hub/src/store/machines.test.ts index 0398278071..fdba2f3675 100644 --- a/hub/src/store/machines.test.ts +++ b/hub/src/store/machines.test.ts @@ -49,6 +49,85 @@ describe('mergeMachineMetadata', () => { it('returns undefined when the merge is a no-op', () => { expect(mergeMachineMetadata({ host: 'a' }, { host: 'a' })).toBeUndefined() }) + + it('clears omitted runner ads when clearOmittedRunnerAds is set', () => { + const merged = mergeMachineMetadata( + { + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + startedCliMtimeMs: 1, + installedCliMtimeMs: 2, + displayName: 'keep-me', + }, + { host: 'box', supervisedRestart: false }, + { clearOmittedRunnerAds: true }, + ) + expect(merged).toEqual({ + host: 'box', + supervisedRestart: false, + displayName: 'keep-me', + }) + }) + + it('keeps sticky runner ads without clearOmittedRunnerAds (terminal bootstrap)', () => { + const merged = mergeMachineMetadata( + { host: 'box', capabilities: ['stop-runner'], supervisedRestart: true }, + { host: 'box' }, + ) + expect(merged).toBeUndefined() + }) +}) + +describe('runner metadata ad clear on re-registration', () => { + it('drops sticky supervisedRestart and capabilities when runner re-registers without them', () => { + const store = new Store(':memory:') + store.machines.getOrCreateMachine( + 'machine-1', + { + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + startedCliMtimeMs: 10, + }, + { status: 'running', pid: 1 }, + 'ns', + ) + + const refreshed = store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box', supervisedRestart: false }, + { status: 'running', pid: 2 }, + 'ns', + ) + + expect(refreshed.metadata).toEqual({ host: 'box', supervisedRestart: false }) + expect(refreshed.metadata).not.toHaveProperty('capabilities') + expect(refreshed.metadata).not.toHaveProperty('startedCliMtimeMs') + }) + + it('does not clear runner ads on terminal-only metadata refresh (no runnerState)', () => { + const store = new Store(':memory:') + store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box', capabilities: ['stop-runner'], supervisedRestart: true }, + { status: 'running', pid: 1 }, + 'ns', + ) + + const refreshed = store.machines.getOrCreateMachine( + 'machine-1', + { host: 'box' }, + null, + 'ns', + ) + + expect(refreshed.metadata).toEqual({ + host: 'box', + capabilities: ['stop-runner'], + supervisedRestart: true, + }) + }) }) describe('runner capabilities backfill', () => { diff --git a/hub/src/store/machines.ts b/hub/src/store/machines.ts index 892f5a39fb..6e8f4c50d4 100644 --- a/hub/src/store/machines.ts +++ b/hub/src/store/machines.ts @@ -44,10 +44,33 @@ function isPlainObject(value: unknown): value is Record { // machine-owned fields over the stored ones so registration doubles as a // refresh; hub-side fields the CLI never sends (e.g. displayName) survive. // Returns undefined when the merge would not change anything. -export function mergeMachineMetadata(stored: unknown, incoming: unknown): Record | undefined { +// +// When `clearOmittedRunnerAds` is set (full runner daemon registration with +// runnerState), runner-advertised keys omitted from incoming are deleted so +// rollback / unsupervised restart cannot leave sticky capabilities or +// supervisedRestart:true (#1108 bot Major). +export const RUNNER_ADVERTISED_METADATA_KEYS = [ + 'capabilities', + 'supervisedRestart', + 'startedCliMtimeMs', + 'installedCliMtimeMs', +] as const + +export function mergeMachineMetadata( + stored: unknown, + incoming: unknown, + options?: { clearOmittedRunnerAds?: boolean }, +): Record | undefined { if (!isPlainObject(incoming)) return undefined const base = isPlainObject(stored) ? stored : {} - const merged = { ...base, ...incoming } + const merged: Record = { ...base, ...incoming } + if (options?.clearOmittedRunnerAds) { + for (const key of RUNNER_ADVERTISED_METADATA_KEYS) { + if (!(key in incoming)) { + delete merged[key] + } + } + } return JSON.stringify(merged) === JSON.stringify(base) ? undefined : merged } @@ -82,7 +105,11 @@ export function getOrCreateMachine( if (stored.namespace !== namespace) { throw new Error('Machine namespace mismatch') } - const merged = mergeMachineMetadata(stored.metadata, metadata) + const merged = mergeMachineMetadata(stored.metadata, metadata, { + // Full runner registration (with runnerState) owns the skew ads — + // omit means clear, so rollback cannot leave sticky supervisedRestart. + clearOmittedRunnerAds: runnerState !== null && runnerState !== undefined, + }) let current = stored if (merged !== undefined) { db.prepare(` diff --git a/web/src/components/NewSession/MachineSelector.tsx b/web/src/components/NewSession/MachineSelector.tsx index 0fc97aff3b..553ddeadb8 100644 --- a/web/src/components/NewSession/MachineSelector.tsx +++ b/web/src/components/NewSession/MachineSelector.tsx @@ -9,14 +9,14 @@ function getMachineTitle(machine: Machine): string { return machine.id.slice(0, 8) } -function getMachineOptionLabel(machine: Machine): string { +function getMachineOptionLabel(machine: Machine, updateRequiredLabel: string): string { const title = getMachineTitle(machine) const platform = machine.metadata?.platform ? ` (${machine.metadata.platform})` : '' const version = machine.metadata?.happyCliVersion ? ` · CLI ${machine.metadata.happyCliVersion}` : '' const skew = machine.active && isMachineCapabilitySkewed(machine.metadata?.capabilities) - ? ' · UPDATE REQUIRED' + ? ` · ${updateRequiredLabel}` : '' return `${title}${platform}${version}${skew}` } @@ -49,7 +49,7 @@ export function MachineSelector(props: { )} {props.machines.map((m) => ( ))} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 8393bcb7bc..debf8735cd 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -243,6 +243,7 @@ export default { 'session.action.reopenCursorMissing': 'Cursor chat data is no longer available on the recorded machine.', 'session.action.reopenCursorCheckFailed': 'Could not verify Cursor chat data on the recorded machine.', 'session.action.reopenCursorUnverified': 'Could not verify Cursor chat data (runner may be outdated). Reopen is still available — upgrade the CLI on that machine if reopen fails.', + 'runner.skew.updateRequired': 'UPDATE REQUIRED', 'runner.skew.banner.title': 'Runner out of date on {host}', 'runner.skew.banner.body': 'This hub needs a newer HAPI CLI on {host} (running {version}). Upgrade the CLI on that machine, then restart the runner (`systemctl --user restart hapi-runner` or `hapi runner start`). This banner stays until the runner reports the required capabilities.', 'runner.skew.banner.bodyUnknownVersion': 'This hub needs a newer HAPI CLI on {host}. Upgrade the CLI on that machine, then restart the runner (`systemctl --user restart hapi-runner` or `hapi runner start`). This banner stays until the runner reports the required capabilities.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index f93ea81a55..cfb6b026ec 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -243,6 +243,7 @@ export default { 'session.action.reopenCursorMissing': '记录设备上的 Cursor 聊天数据已不可用。', 'session.action.reopenCursorCheckFailed': '无法验证记录设备上的 Cursor 聊天数据。', 'session.action.reopenCursorUnverified': '无法验证 Cursor 聊天数据(运行器可能过旧)。仍可尝试重新打开——若失败,请在该机器上升级 CLI。', + 'runner.skew.updateRequired': '需要更新', 'runner.skew.banner.title': '{host} 上的运行器版本过旧', 'runner.skew.banner.body': '此 Hub 需要 {host} 上更新的 HAPI CLI(当前 {version})。请在该机器上升级 CLI,然后重启运行器(`systemctl --user restart hapi-runner` 或 `hapi runner start`)。在运行器报告所需能力之前,此横幅会一直显示。', 'runner.skew.banner.bodyUnknownVersion': '此 Hub 需要 {host} 上更新的 HAPI CLI。请在该机器上升级 CLI,然后重启运行器(`systemctl --user restart hapi-runner` 或 `hapi runner start`)。在运行器报告所需能力之前,此横幅会一直显示。',