From 6bc4d8ef9a365d553704f67973dc809171827968 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 1 Sep 2026 11:52:21 +0800 Subject: [PATCH 1/2] fix(desktop): recover managed Runtime Host startup Generated-by: OpenAI Codex --- .../native-diagnostic-dialog.test.ts | 30 ++++ .../runtime-host-local-remote-access.test.ts | 60 ++++++++ .../runtime-host-startup-recovery.test.ts | 118 +++++++++++++++ .../src/main/native-diagnostic-dialog.ts | 62 ++++++++ apps/desktop/src/main/runtime-host-boot.ts | 53 ++++++- .../src/main/runtime-host-local-operator.ts | 2 + .../main/runtime-host-local-remote-access.ts | 61 ++++++++ .../src/main/runtime-host-startup-recovery.ts | 137 ++++++++++++++++++ ...runtime-host-lifecycle-transaction.test.ts | 61 ++++++++ .../runtime-host-selected-update.test.ts | 76 ++++++++++ packages/cli/src/cli-core.ts | 1 + packages/cli/src/runtime-host-cli.ts | 30 +++- .../src/runtime-host-lifecycle-transaction.ts | 9 +- .../runtime-host-managed-lifecycle-manager.ts | 1 + .../cli/src/runtime-host-update-command.ts | 23 ++- .../cli/src/runtime-host-update-discovery.ts | 2 + .../__tests__/authenticated-websocket.test.ts | 16 +- .../src/server/access-credential-store.ts | 8 +- 18 files changed, 732 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts create mode 100644 apps/desktop/src/main/runtime-host-startup-recovery.ts diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index f2db20c362..cb70416103 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -28,6 +28,7 @@ import { showFatalStartupError, showMainRendererProcessGoneDialog, showMessageBoxWithDiagnostics, + showRuntimeHostStartupRecoveryDialog, } from '../native-diagnostic-dialog.js'; const diagnosticEnvironment = () => ({ @@ -149,3 +150,32 @@ test('main Renderer loss keeps Copy Diagnostics auxiliary to recovery', async () assert.match(clipboard, /Recent main-process logs \(1\)/); assert.doesNotMatch(clipboard, /very-secret-token/); }); + +test('managed Host recovery preserves the workspace and confirms active-work interruption', async () => { + let shown: MessageBoxOptions | undefined; + const decision = await showRuntimeHostStartupRecoveryDialog( + { + startupError: new Error('managed service unavailable'), + repairError: new Error('service update failed'), + activeTasks: true, + }, + { + locale: 'en', + copyDiagnostics() {}, + showMessageBox: async (options): Promise => { + shown = options; + return { response: 0, checkboxChecked: false }; + }, + }, + ); + + assert.equal(decision, 'repair'); + assert.deepEqual(shown?.buttons, [ + 'Repair and Restart Host', + 'Exit', + 'Copy Diagnostics', + ]); + assert.match(shown?.detail ?? '', /workspace, Host identity, credentials, and settings/); + assert.match(shown?.detail ?? '', /interrupt that work/); + assert.match(shown?.detail ?? '', /service update failed/); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts index c72e59aa6c..9e502095c6 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts @@ -234,6 +234,66 @@ test('keeps the managed service visible when Direct peer support is unavailable' assert.equal(snapshot.managedService, true); }); +test('repairs an existing managed Host with the current setup package and restarts it when already current', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-repair-')); + t.after(() => rm(base, { recursive: true, force: true })); + const clientDataRoot = join(base, 'client'); + const rootPath = join(clientDataRoot, 'workspaces', 'default'); + const rootId = 'a'.repeat(64); + const setupPackage = { kind: 'npm' as const, specifier: 'maka-agent@0.2.0' }; + await mkdir(rootPath, { recursive: true }); + await writeManagedLifecycle(clientDataRoot, rootPath, rootId); + const actions: string[] = []; + const service = createDesktopLocalRuntimeHostRemoteAccess({ + ipcMain: { handle() {}, removeHandler() {} }, + clientDataRoot, + rootPath, + rootId, + directPeerAvailable: true, + manager: () => undefined, + resolveSetupPackage: async () => setupPackage, + operator: { + async runUpdate(input: { + readonly setupPackage: unknown; + readonly target: { readonly rootId: string; readonly deploymentId?: string }; + readonly allowManualUpdate?: boolean; + readonly allowInterruptActiveTasks?: boolean; + }) { + actions.push('update'); + assert.equal(input.setupPackage, setupPackage); + assert.equal(input.target.rootId, rootId); + assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID); + assert.equal(input.allowManualUpdate, true); + assert.equal(input.allowInterruptActiveTasks, undefined); + return { + kind: 'result', + action: 'update', + update: { kind: 'already_current', version: '0.2.0' }, + } as never; + }, + async runService(input: { + readonly action: string; + readonly target: { readonly rootId: string; readonly deploymentId?: string }; + readonly allowInterruptActiveTasks?: boolean; + }) { + actions.push(input.action); + assert.equal(input.action, 'restart'); + assert.equal(input.target.rootId, rootId); + assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID); + assert.equal(input.allowInterruptActiveTasks, undefined); + return { kind: 'result', action: 'restart' } as never; + }, + async close() {}, + } as unknown as ReturnType, + }); + t.after(() => service.close()); + + assert.deepEqual(await service.repairManagedStartup({ allowManualUpdate: true }), { + kind: 'repaired', + }); + assert.deepEqual(actions, ['update', 'restart']); +}); + test('replaces a conflicting supervised Host through canonical authority without a receipt', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-')); t.after(() => rm(base, { recursive: true, force: true })); diff --git a/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts b/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts new file mode 100644 index 0000000000..8074ee6915 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-startup-recovery.test.ts @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { runtimeHostStartupError } from "@maka/runtime-host/client"; +import { startDesktopRuntimeHostWithRecovery } from "../runtime-host-startup-recovery.js"; + +test("repairs a managed Host once and resumes startup without asking the user", async () => { + let starts = 0; + const repairModes: Array<{ + readonly allowManualUpdate: boolean; + readonly allowInterruptActiveTasks: boolean; + }> = []; + let prompts = 0; + + const result = await startDesktopRuntimeHostWithRecovery({ + start: async () => { + starts += 1; + if (starts === 1) + throw runtimeHostStartupError("managed_root_requires_operator"); + return "ready"; + }, + repair: async (authority) => { + repairModes.push(authority); + return { kind: "repaired" }; + }, + prompt: async () => { + prompts += 1; + return "exit"; + }, + }); + + assert.equal(result, "ready"); + assert.equal(starts, 2); + assert.deepEqual(repairModes, [ + { allowManualUpdate: false, allowInterruptActiveTasks: false }, + ]); + assert.equal(prompts, 0); +}); + +test("separates manual update consent from active-work interruption", async () => { + let starts = 0; + const repairModes: Array<{ + readonly allowManualUpdate: boolean; + readonly allowInterruptActiveTasks: boolean; + }> = []; + const prompts: boolean[] = []; + + const result = await startDesktopRuntimeHostWithRecovery({ + start: async () => { + starts += 1; + if (starts === 1) + throw runtimeHostStartupError("managed_root_requires_operator"); + return "ready"; + }, + repair: async (authority) => { + repairModes.push(authority); + if (!authority.allowManualUpdate) throw new Error("manual update confirmation required"); + return authority.allowInterruptActiveTasks + ? { kind: "repaired" } + : { kind: "active_tasks" }; + }, + prompt: async (input) => { + prompts.push(input.activeTasks); + return "repair"; + }, + }); + + assert.equal(result, "ready"); + assert.deepEqual(repairModes, [ + { allowManualUpdate: false, allowInterruptActiveTasks: false }, + { allowManualUpdate: true, allowInterruptActiveTasks: false }, + { allowManualUpdate: true, allowInterruptActiveTasks: true }, + ]); + assert.deepEqual(prompts, [false, true]); +}); + +test("does not offer managed repair for an unrelated startup failure", async () => { + const failure = new Error("renderer prerequisites failed"); + let repairs = 0; + let prompts = 0; + + await assert.rejects( + startDesktopRuntimeHostWithRecovery({ + start: async () => { + throw failure; + }, + repair: async () => { + repairs += 1; + return { kind: "repaired" }; + }, + prompt: async () => { + prompts += 1; + return "repair"; + }, + }), + failure, + ); + assert.equal(repairs, 0); + assert.equal(prompts, 0); +}); diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index d7d2fb4677..920dcacf7b 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -43,6 +43,12 @@ interface FatalStartupDiagnosticDialogDeps { readonly showMessageBox: (options: MessageBoxOptions) => Promise; } +export interface RuntimeHostStartupRecoveryDialogInput { + readonly startupError: Error; + readonly repairError?: Error; + readonly activeTasks: boolean; +} + export async function showMessageBoxWithDiagnostics( options: MessageBoxOptions, deps: DiagnosticDialogDeps, @@ -114,6 +120,36 @@ export async function showMainRendererProcessGoneDialog( return result.response === 0 ? 'relaunch' : 'exit'; } +export async function showRuntimeHostStartupRecoveryDialog( + input: RuntimeHostStartupRecoveryDialogInput, + deps: DiagnosticDialogDeps, +): Promise<'repair' | 'exit'> { + const copy = RUNTIME_HOST_STARTUP_RECOVERY_COPY[deps.locale]; + const detail = [ + copy.detail, + input.activeTasks ? copy.activeTasks : undefined, + input.repairError + ? `${copy.repairFailed}\n${input.repairError.message}` + : undefined, + ] + .filter(Boolean) + .join('\n\n'); + const result = await showMessageBoxWithDiagnostics( + { + type: 'warning', + title: copy.title, + message: copy.message, + detail, + buttons: [input.activeTasks ? copy.repairAndRestart : copy.repair, copy.exit], + defaultId: 0, + cancelId: 1, + noLink: true, + }, + deps, + ); + return result.response === 0 ? 'repair' : 'exit'; +} + async function copyDiagnostics( copy: () => void | Promise, locale: UiLocale, @@ -195,3 +231,29 @@ const MAIN_RENDERER_GONE_COPY = { exit: '退出', }, } as const; + +const RUNTIME_HOST_STARTUP_RECOVERY_COPY = { + en: { + title: 'Maka needs to repair Runtime Host', + message: 'The Runtime Host for this workspace could not start.', + detail: + 'Maka can repair the managed Runtime Host selected by this Desktop. Your workspace, Host identity, credentials, and settings will be preserved.', + activeTasks: + 'The Host may still own active work. Continuing can interrupt that work before the Host restarts.', + repairFailed: 'The previous repair attempt did not finish:', + repair: 'Repair Runtime Host', + repairAndRestart: 'Repair and Restart Host', + exit: 'Exit', + }, + zh: { + title: 'Maka 需要修复 Runtime Host', + message: '管理此工作区的 Runtime Host 无法启动。', + detail: + 'Maka 可以修复此 Desktop 选择的托管 Runtime Host。工作区、Host 身份、凭证和设置都会保留。', + activeTasks: 'Host 可能仍有正在运行的任务。继续会先中断这些任务,再重启 Host。', + repairFailed: '上一次修复未能完成:', + repair: '修复 Runtime Host', + repairAndRestart: '修复并重启 Host', + exit: '退出', + }, +} as const; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index a48483b34a..9c1de141f7 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -116,6 +116,7 @@ import { import { showMainRendererProcessGoneDialog, showMessageBoxWithDiagnostics, + showRuntimeHostStartupRecoveryDialog, } from "./native-diagnostic-dialog.js"; import { resolveDesktopSessionWorkspace, @@ -168,6 +169,10 @@ import { startRuntimeHostDesktopManager, type RuntimeHostDesktopManager, } from "./runtime-host-desktop-manager.js"; +import { + DesktopRuntimeHostStartupRecoveryCancelledError, + startDesktopRuntimeHostWithRecovery, +} from "./runtime-host-startup-recovery.js"; import { buildRuntimeHostQuitFailureDialog } from "./runtime-host-quit-copy.js"; import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js"; import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js"; @@ -850,8 +855,7 @@ registerNotificationsIpc({ }); const sessionCopyOwnerProcessId = randomUUID(); -await localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(); -runtimeHostManager = await startRuntimeHostDesktopManager( +const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( { rootPath: workspaceRoot, clientInstanceId: runtimeHostClientInstanceId, @@ -1074,6 +1078,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( resolveLocalHostReplacement: (registration, signal) => localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal), onFatalError: (error, target) => { + if (!runtimeHostManager && target.profile.kind === "local") return; if (error instanceof RuntimeHostUpgradeCancelledError) { if (target.profile.kind === "local") app.quit(); return; @@ -1082,8 +1087,48 @@ runtimeHostManager = await startRuntimeHostDesktopManager( if (target.profile.kind === "local") app.quit(); }, }, -).catch((error: unknown) => { - if (error instanceof RuntimeHostUpgradeCancelledError) { +); +runtimeHostManager = await startDesktopRuntimeHostWithRecovery({ + start: async () => { + await localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(); + return startLocalRuntimeHostManager(); + }, + repair: async ({ allowManualUpdate, allowInterruptActiveTasks }) => { + console.warn('[runtime-host] repairing the managed Local Host before startup'); + const result = await localRuntimeHostRemoteAccess.repairManagedStartup({ + allowManualUpdate, + allowInterruptActiveTasks, + }); + console.log(`[runtime-host] managed Local Host repair result: ${result.kind}`); + return result; + }, + prompt: async (input) => { + console.error('[runtime-host] managed Local Host startup recovery requires attention:', { + startupError: input.startupError, + repairError: input.repairError, + activeTasks: input.activeTasks, + }); + return showRuntimeHostStartupRecoveryDialog(input, { + locale: desktopLocale.current(), + showMessageBox: (options) => dialog.showMessageBox(options), + copyDiagnostics: () => + copyDesktopDiagnosticReport( + desktopDiagnostics, + createDesktopStartupDiagnosticInput({ + title: 'Runtime Host startup recovery', + description: input.startupError.message, + details: [input.startupError.stack, input.repairError?.stack] + .filter(Boolean) + .join('\n\n'), + }), + ), + }); + }, +}).catch((error: unknown) => { + if ( + error instanceof RuntimeHostUpgradeCancelledError || + error instanceof DesktopRuntimeHostStartupRecoveryCancelledError + ) { app.quit(); return new Promise(() => undefined); } diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts index 34f2ea2c9d..e8179f9195 100644 --- a/apps/desktop/src/main/runtime-host-local-operator.ts +++ b/apps/desktop/src/main/runtime-host-local-operator.ts @@ -198,6 +198,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { readonly setupPackage: DesktopRuntimeHostSetupPackage; readonly target: DesktopRuntimeHostLocalServiceTarget; readonly expectedHost?: { readonly hostEpoch: string; readonly pid: number }; + readonly allowManualUpdate?: boolean; readonly allowInterruptActiveTasks?: boolean; readonly signal?: AbortSignal; }, @@ -424,6 +425,7 @@ export function createDesktopRuntimeHostLocalOperator(input: { ? ['--expected-host-json', JSON.stringify(command.expectedHost)] : []), ...managedTargetArgs(command.target), + ...(command.allowManualUpdate ? ['--allow-manual-update'] : []), ...(command.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []), ], }, diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts index 01e084c277..4ade5c5610 100644 --- a/apps/desktop/src/main/runtime-host-local-remote-access.ts +++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts @@ -93,6 +93,11 @@ export interface DesktopRuntimeHostLocalManagementTarget readonly deploymentId: string; } +export type DesktopRuntimeHostManagedStartupRepairResult = + | { readonly kind: 'repaired' } + | { readonly kind: 'active_tasks' } + | { readonly kind: 'unavailable' }; + export interface DesktopLocalRuntimeHostRemoteAccess { getSnapshot(): Promise; createCollaborationConnectionTarget(): Promise<{ @@ -117,6 +122,11 @@ export interface DesktopLocalRuntimeHostRemoteAccess { registration: HostRegistration, signal: AbortSignal, ): Promise<{ replace(): Promise } | undefined>; + repairManagedStartup(input?: { + readonly allowManualUpdate?: boolean; + readonly allowInterruptActiveTasks?: boolean; + readonly signal?: AbortSignal; + }): Promise; recoverBeforeLocalHostStart(signal?: AbortSignal): Promise; recover(): Promise; close(): Promise; @@ -747,6 +757,57 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: { }), }; }, + repairManagedStartup: (options = {}) => + serialize(async () => { + const signal = options.signal + ? AbortSignal.any([options.signal, closing.signal]) + : closing.signal; + signal.throwIfAborted(); + const lifecycle = await readLifecycle(lifecyclePath, input.rootPath, input.rootId); + if (lifecycle?.state !== 'managed') return { kind: 'unavailable' }; + + const setupPackage = await input.resolveSetupPackage(signal); + const frame = await input.operator.runUpdate( + { + setupPackage, + target: lifecycle, + ...(options.allowManualUpdate ? { allowManualUpdate: true } : {}), + ...(options.allowInterruptActiveTasks + ? { allowInterruptActiveTasks: true } + : {}), + signal, + }, + () => undefined, + ); + if (frame.kind === 'error') { + if (frame.error.code === 'active_tasks') return { kind: 'active_tasks' }; + throw new Error(`Runtime Host repair failed: ${frame.error.message}`); + } + if (frame.kind === 'progress' || frame.action !== 'update') { + throw new Error('Runtime Host repair returned an unrelated result'); + } + if (frame.update.kind === 'active_tasks') return { kind: 'active_tasks' }; + + if (frame.update.kind === 'already_current') { + const restarted = await input.operator.runService({ + operatorPath: lifecycle.operatorPath, + action: 'restart', + target: lifecycle, + ...(options.allowInterruptActiveTasks + ? { allowInterruptActiveTasks: true } + : {}), + signal, + }); + if (restarted.kind === 'error') { + if (restarted.error.code === 'active_tasks') return { kind: 'active_tasks' }; + throw new Error(`Runtime Host restart failed: ${restarted.error.message}`); + } + if (restarted.kind === 'progress' || restarted.action !== 'restart') { + throw new Error('Runtime Host restart returned an unrelated result'); + } + } + return { kind: 'repaired' }; + }), recoverBeforeLocalHostStart: async (signal) => { const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal; operationSignal.throwIfAborted(); diff --git a/apps/desktop/src/main/runtime-host-startup-recovery.ts b/apps/desktop/src/main/runtime-host-startup-recovery.ts new file mode 100644 index 0000000000..3f6b282986 --- /dev/null +++ b/apps/desktop/src/main/runtime-host-startup-recovery.ts @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RuntimeHostStartupError } from "@maka/runtime-host/client"; + +export type DesktopRuntimeHostStartupRepairResult = + | { readonly kind: "repaired" } + | { readonly kind: "active_tasks" } + | { readonly kind: "unavailable" }; + +export interface DesktopRuntimeHostStartupRecoveryPrompt { + readonly startupError: Error; + readonly repairError?: Error; + readonly activeTasks: boolean; +} + +export interface DesktopRuntimeHostStartupRepairAuthority { + readonly allowManualUpdate: boolean; + readonly allowInterruptActiveTasks: boolean; +} + +export class DesktopRuntimeHostStartupRecoveryCancelledError extends Error { + readonly name = "DesktopRuntimeHostStartupRecoveryCancelledError"; + + constructor(options?: ErrorOptions) { + super("Runtime Host startup recovery was cancelled", options); + } +} + +export async function startDesktopRuntimeHostWithRecovery(input: { + readonly start: () => Promise; + readonly repair: ( + authority: DesktopRuntimeHostStartupRepairAuthority, + ) => Promise; + readonly prompt: ( + input: DesktopRuntimeHostStartupRecoveryPrompt, + ) => Promise<"repair" | "exit">; +}): Promise { + let startupError: Error; + try { + return await input.start(); + } catch (error) { + startupError = asError(error); + if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; + } + + let activeTasks = false; + let repairError: Error | undefined; + let automatic: DesktopRuntimeHostStartupRepairResult | undefined; + try { + automatic = await input.repair({ + allowManualUpdate: false, + allowInterruptActiveTasks: false, + }); + } catch (error) { + repairError = asError(error); + } + if (automatic?.kind === "unavailable") throw startupError; + if (automatic?.kind === "active_tasks") activeTasks = true; + if (automatic?.kind === "repaired") { + try { + return await input.start(); + } catch (error) { + startupError = asError(error); + if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; + } + } + + for (;;) { + const decision = await input.prompt({ + startupError, + ...(repairError ? { repairError } : {}), + activeTasks, + }); + if (decision === "exit") { + throw new DesktopRuntimeHostStartupRecoveryCancelledError({ + cause: startupError, + }); + } + + let repaired: DesktopRuntimeHostStartupRepairResult; + try { + repaired = await input.repair({ + allowManualUpdate: true, + allowInterruptActiveTasks: activeTasks, + }); + } catch (error) { + repairError = asError(error); + continue; + } + if (repaired.kind === "unavailable") throw startupError; + if (repaired.kind === "active_tasks") { + activeTasks = true; + repairError = undefined; + continue; + } + try { + return await input.start(); + } catch (error) { + startupError = asError(error); + if (!canRepairManagedRuntimeHostStartup(startupError)) throw startupError; + repairError = undefined; + } + } +} + +export function canRepairManagedRuntimeHostStartup(error: Error): boolean { + return ( + error instanceof RuntimeHostStartupError && + (error.reason === "managed_root_requires_operator" || + error.reason === "deployment_claim_mismatch" || + error.reason === "deployment_lifecycle_mismatch" || + error.reason === "deployment_launch_mismatch" || + error.reason === "deployment_transition_in_progress" || + error.reason === "deployment_needs_repair") + ); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts index 30a2b93f04..195ae429ff 100644 --- a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts +++ b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts @@ -42,6 +42,7 @@ import { applyRuntimeHostLifecycleTransition, recoverRuntimeHostLifecycleTransition, replaceRuntimeHostLifecycle, + resolveRecoverableRuntimeHostManagedDeployment, retireRuntimeHostLifecycleOwner, runtimeHostReconciliationTriggerDefinition, runtimeHostSupervisorDefinition, @@ -532,6 +533,66 @@ test('does not consume replacement consent after the supervised Host exits', asy assert.equal(retired, false); }); +test('requires explicit interruption authority to recover an unreachable supervised transition', async (t) => { + const stateRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-recovery-consent-')); + const capability = await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }); + const authorityDirectory = dirname( + resolveRuntimeHostManagedDeploymentConfigPath(capability.rootId), + ); + t.after(() => rm(stateRoot, { recursive: true, force: true })); + t.after(() => rm(authorityDirectory, { recursive: true, force: true })); + + const current = config(capability.canonicalPath, capability.rootId, 1, 'launch_agent'); + const desired = config(capability.canonicalPath, capability.rootId, 2, 'launch_agent'); + await claimRuntimeHostManagedDeployment(capability, current); + const provider = new FakeLifecycleProvider('launch_agent', 'launch_agent_timer'); + provider.install(current); + provider.running = 42; + const interruptedOwner = await tryAcquireStateRootOwner(capability); + assert.ok(interruptedOwner); + await beginRuntimeHostManagedDeploymentTransition(interruptedOwner, { + transactionId: '00000000-0000-4000-8000-000000000101', + operation: 'lifecycle_change', + recovery: 'complete_to', + expected: current, + desired, + }); + Object.assign(provider.supervisor, { + activate: async () => { + provider.running = 43; + }, + retire: async () => { + provider.running = null; + await interruptedOwner.close(); + }, + }); + const deps: RuntimeHostLifecycleTransactionDeps = { + convergeOperator: async () => undefined, + verifyOperator: async () => undefined, + resolveProvider: () => provider, + connectExisting: async () => + ({ + kind: 'connected', + connection: { + rootId: capability.rootId, + request: async () => ({ pid: 43 }), + status: async () => ({ state: 'ready' }), + close: async () => undefined, + }, + }) as unknown as Awaited>, + }; + + await assert.rejects(resolveRecoverableRuntimeHostManagedDeployment(capability.rootId, deps), { + code: 'active_tasks', + }); + const recovered = await resolveRecoverableRuntimeHostManagedDeployment(capability.rootId, deps, { + allowInterruptActiveTasks: true, + }); + + assert.equal(recovered.kind, 'active'); + assert.deepEqual(recovered.kind === 'active' ? recovered.config : undefined, desired); +}); + test('readiness waits for a reachable Host to leave the starting state', async (t) => { const stateRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-ready-')); t.after(() => rm(stateRoot, { recursive: true, force: true })); diff --git a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts index eaf67ceedf..3812f2adbf 100644 --- a/packages/cli/src/__tests__/runtime-host-selected-update.test.ts +++ b/packages/cli/src/__tests__/runtime-host-selected-update.test.ts @@ -27,10 +27,12 @@ import { type RuntimeHostUpdateCliOptions, } from '../runtime-host-update-command.js'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; +import { RuntimeHostLifecycleTransactionError } from '../runtime-host-lifecycle-transaction.js'; import type { RuntimeHostUpdateSelection } from '../runtime-host-update-discovery.js'; const INTEGRITY = 'sha512-jUKdo/5dbM94KXq+kOZ1d+obhDLAENfI/QWr1PnXWcdu2PqDyLklJBtiVO6HRwoL1l40z1NE9Rq+hLAxCN0Fyg=='; +const DEPLOYMENT_ID = '00000000-0000-4000-8000-000000000001'; const TARGET = { serviceId: 'b'.repeat(64), rootPath: '/srv/maka', @@ -118,6 +120,53 @@ describe('managed Runtime Host selected update', () => { ); }); + it('requires exact canonical deployment authority for manual update consent', () => { + const args = [ + 'service', + 'update', + '--target', + '2.0.0', + '--allow-manual-update', + '--managed-root-id', + TARGET.rootId, + '--expected-service-id', + TARGET.serviceId, + '--expected-root-path', + TARGET.rootPath, + '--expected-root-id', + TARGET.rootId, + '--expected-deployment-id', + DEPLOYMENT_ID, + ]; + assert.deepEqual(parseRuntimeHostCommand(args), { + kind: 'runtime-host-service-update', + json: false, + managedRootId: TARGET.rootId, + selector: { kind: 'exact', version: '2.0.0' }, + expectedTarget: { ...TARGET, deploymentId: DEPLOYMENT_ID }, + allowManualUpdate: true, + }); + }); + + it('carries interruption authority through selection and preserves active work', async () => { + let output = ''; + const exitCode = await runManagedRuntimeHostSelectedUpdateCli( + { ...OPTIONS, allowInterruptActiveTasks: true }, + { + resolveSelection: async (options) => { + assert.equal(options.allowInterruptActiveTasks, true); + throw new RuntimeHostLifecycleTransactionError('active_tasks', 'active work'); + }, + writeOutput(value) { + output += value; + }, + }, + ); + const frame = decodeRuntimeHostServiceManagementFrame(output.trim()); + assert.equal(exitCode, 1); + assert.equal(frame?.kind === 'error' ? frame.error.code : undefined, 'active_tasks'); + }); + it('hands one verified admitted package to the existing update transaction', async () => { const selection = updateSelection({ kind: 'unattended_update', @@ -206,6 +255,33 @@ describe('managed Runtime Host selected update', () => { 0, ); assert.deepEqual(updateInput?.expectedHost, { hostEpoch: 'older-host', pid: 42 }); + + const legacySelection = updateSelection({ + kind: 'manual_action', + reason: 'current_compatibility_unknown', + }); + const recoveryUpdates: RuntimeHostUpdateCliOptions[] = []; + assert.equal( + await runManagedRuntimeHostSelectedUpdateCli( + { + ...OPTIONS, + managedRootId: TARGET.rootId, + expectedTarget: { ...TARGET, deploymentId: DEPLOYMENT_ID }, + allowManualUpdate: true, + }, + { + resolveSelection: async () => legacySelection, + withPackage: async (_candidate, use) => use('/verified/package'), + update: async (input) => { + recoveryUpdates.push(input); + return 0; + }, + }, + ), + 0, + ); + assert.equal(recoveryUpdates[0]?.expectedHost, undefined); + assert.equal(recoveryUpdates[0]?.allowInterruptActiveTasks, undefined); }); }); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index e99c46dbea..b74adcbd71 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -559,6 +559,7 @@ export async function runMakaCli( ...(command.operatorDeploymentId ? { operatorDeploymentId: command.operatorDeploymentId } : {}), + ...(command.allowManualUpdate ? { allowManualUpdate: true } : {}), ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }); } diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index 5f7c52f7ac..505fa9eed6 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -233,6 +233,7 @@ export type RuntimeHostCliCommand = expectedTarget: RuntimeHostManagedServiceTarget; expectedHost?: RuntimeHostExpectedHost; selector?: RuntimeHostUpdateSelector; + allowManualUpdate?: true; allowInterruptActiveTasks?: true; } | { @@ -809,6 +810,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { let retainManagedDeployment = false; let allowInterruptActiveTasks = false; + let allowManualUpdate = false; let clientDataRoot: string | undefined; let updateTarget: string | undefined; let expectedHost: RuntimeHostExpectedHost | undefined; @@ -827,7 +829,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { allowInterruptActiveTasks = true; }, } - : action === 'restart' || action === 'retire' || action === 'update' || action === 'configure' + : action === 'update' ? { '--allow-interrupt-active-tasks': () => { if (allowInterruptActiveTasks) { @@ -835,8 +837,21 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { } allowInterruptActiveTasks = true; }, + '--allow-manual-update': () => { + if (allowManualUpdate) return error('Duplicate --allow-manual-update'); + allowManualUpdate = true; + }, } - : {}; + : action === 'restart' || action === 'retire' || action === 'configure' + ? { + '--allow-interrupt-active-tasks': () => { + if (allowInterruptActiveTasks) { + return error('Duplicate --allow-interrupt-active-tasks'); + } + allowInterruptActiveTasks = true; + }, + } + : {}; const options = parseManagedServiceOptions(argv.slice(1), { allowConfiguration: action === 'install' || action === 'configure', allowFramed: true, @@ -953,6 +968,16 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { const selector = updateTarget === undefined ? undefined : parseUpdateSelector(updateTarget, 'update'); if (selector && 'kind' in selector && selector.kind === 'error') return selector; + if ( + allowManualUpdate && + (selector?.kind !== 'exact' || + !options.managedRootId || + !options.expectedTarget?.deploymentId) + ) { + return error( + '--allow-manual-update requires an exact --target, --managed-root-id, and --expected-deployment-id', + ); + } return { kind: 'runtime-host-service-update', json: options.json, @@ -965,6 +990,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand { expectedTarget: options.expectedTarget!, ...(expectedHost ? { expectedHost } : {}), ...(selector ? { selector } : {}), + ...(allowManualUpdate ? { allowManualUpdate: true } : {}), ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }; } diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index 5722f5430b..3f541919c9 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -86,7 +86,7 @@ export interface RuntimeHostLifecycleTransitionInput { export class RuntimeHostLifecycleTransactionError extends Error { constructor( - readonly code: 'transition_failed' | 'recovery_failed' | 'owner_changed', + readonly code: 'transition_failed' | 'recovery_failed' | 'owner_changed' | 'active_tasks', message: string, options?: ErrorOptions, ) { @@ -213,6 +213,7 @@ export async function resolveRecoverableRuntimeHostManagedDeployment( readonly deploymentId?: string; }; readonly expectedOwner?: { readonly hostEpoch: string; readonly pid: number }; + readonly allowInterruptActiveTasks?: boolean; readonly ensureAvailable?: boolean; } = {}, ): Promise { @@ -237,11 +238,12 @@ export async function resolveRecoverableRuntimeHostManagedDeployment( ? { supervisor: previousProvider.supervisor } : {}), ...(options.expectedOwner ? { expectedOwner: options.expectedOwner } : {}), + allowInterruptActiveTasks: options.allowInterruptActiveTasks ?? false, retireIdleSupervisor: false, }); if (retirement.kind === 'active_tasks') { throw new RuntimeHostLifecycleTransactionError( - 'transition_failed', + 'active_tasks', 'Runtime Host lifecycle recovery is waiting for active work to finish', ); } @@ -367,10 +369,11 @@ export async function retireRuntimeHostLifecycleOwner(input: { 'registration' in connected ? connected.registration : undefined, ); if (connected.kind !== 'connected') { - if (input.allowInterruptActiveTasks && input.supervisor) { + if (input.supervisor) { const status = await input.supervisor.status(); assertExpectedSupervisorOwner(input.expectedOwner, status); if (status.active && status.pid !== null) { + if (!input.allowInterruptActiveTasks) return { kind: 'active_tasks' }; await input.supervisor.retire(); return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000); } diff --git a/packages/cli/src/runtime-host-managed-lifecycle-manager.ts b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts index 34e8ba6584..e9091b9160 100644 --- a/packages/cli/src/runtime-host-managed-lifecycle-manager.ts +++ b/packages/cli/src/runtime-host-managed-lifecycle-manager.ts @@ -78,6 +78,7 @@ export async function manageRuntimeHostManagedLifecycle( }; const resolved = await resolveRecoverableRuntimeHostManagedDeployment(rootId, lifecycleDeps, { ...(input.expectedTarget ? { expectedTarget: input.expectedTarget } : {}), + allowInterruptActiveTasks: input.allowInterruptActiveTasks ?? false, }); if (resolved.kind === 'absent') { if (input.action === 'uninstall' && input.expectedTarget) { diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 41111b4309..c42b7c6d6f 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -113,6 +113,7 @@ export interface RuntimeHostUpdateCliOptions { export interface RuntimeHostSelectedUpdateCliOptions extends Omit { readonly selector: RuntimeHostUpdateSelector; + readonly allowManualUpdate?: boolean; } interface RuntimeHostUpdateCliDeps { @@ -601,6 +602,7 @@ async function runCanonicalRuntimeHostUpdate( { expectedTarget: options.expectedTarget, ...(options.expectedHost ? { expectedOwner: options.expectedHost } : {}), + allowInterruptActiveTasks: options.allowInterruptActiveTasks ?? false, }, ); if (recovered.kind === 'absent') { @@ -774,7 +776,9 @@ async function runCanonicalRuntimeHostUpdate( ? error.code : error instanceof RuntimeHostLifecycleTransactionError && error.code === 'owner_changed' ? 'target_mismatch' - : 'update_incomplete'; + : error instanceof RuntimeHostLifecycleTransactionError && error.code === 'active_tasks' + ? 'active_tasks' + : 'update_incomplete'; emit({ schemaVersion: 1, kind: 'error', @@ -818,6 +822,7 @@ export async function runManagedRuntimeHostSelectedUpdateCli( ...(options.operatorDeploymentId ? { operatorDeploymentId: options.operatorDeploymentId } : {}), + ...(options.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }); return await runManagedRuntimeHostResolvedUpdateCli(options, selection, deps, emit); } catch (error) { @@ -826,7 +831,9 @@ export async function runManagedRuntimeHostSelectedUpdateCli( error instanceof RuntimeHostServiceManagerError || error instanceof RuntimeHostUpdatePackageError ? error.code - : 'update_resolution_failed'; + : error instanceof RuntimeHostLifecycleTransactionError && error.code === 'active_tasks' + ? 'active_tasks' + : 'update_resolution_failed'; const message = error instanceof Error ? error.message : String(error); emit({ schemaVersion: 1, @@ -862,8 +869,10 @@ export async function runManagedRuntimeHostResolvedUpdateCli( if ( selection.outcome.kind === 'manual_action' && !( - options.expectedHost && - options.allowInterruptActiveTasks && + ((options.expectedHost && options.allowInterruptActiveTasks) || + (options.allowManualUpdate && + options.managedRootId && + options.expectedTarget.deploymentId)) && selection.outcome.reason !== 'target_not_newer' ) ) { @@ -883,7 +892,11 @@ export async function runManagedRuntimeHostResolvedUpdateCli( } const apply = async (packageRoot: string) => { - const { selector: _selector, ...updateOptions } = options; + const { + selector: _selector, + allowManualUpdate: _allowManualUpdate, + ...updateOptions + } = options; return await deps.update( { ...updateOptions, diff --git a/packages/cli/src/runtime-host-update-discovery.ts b/packages/cli/src/runtime-host-update-discovery.ts index 0277cba2e3..95fe5e82a7 100644 --- a/packages/cli/src/runtime-host-update-discovery.ts +++ b/packages/cli/src/runtime-host-update-discovery.ts @@ -74,6 +74,7 @@ export interface RuntimeHostUpdateCheckOptions { readonly expectedTarget?: RuntimeHostManagedServiceTarget; readonly managedRootId?: string; readonly operatorDeploymentId?: string; + readonly allowInterruptActiveTasks?: boolean; /** Internal non-reentrant lock ownership propagated by the canonical coordinator. */ readonly deploymentLockHeld?: boolean; } @@ -135,6 +136,7 @@ async function resolveManagedRuntimeHostUpdate( nodePath: process.execPath, cliPath: process.argv[1] ?? '', ...(options.expectedTarget ? { expectedTarget: options.expectedTarget } : {}), + ...(options.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}), }; const backend = options.managedRootId ? undefined diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index d2eafa24ad..e2cb002e9d 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -1364,7 +1364,7 @@ test('keeps a formerly accepted local-only grant inert when opening an existing } }); -test('releases a retired operation grant when opening an existing access file', async () => { +test('migrates or releases retired operation grants when opening an existing access file', async () => { const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-retired-grant-')); const credential = 'maka_rh_existing_usage_client'; try { @@ -1383,7 +1383,14 @@ test('releases a retired operation grant when opening an existing access file', // Claude subscription provider. A file issued before that must // still open — failing decode here kept the Host from starting — // with the unservable grant released rather than migrated. - operationGrants: ['host.status', 'oauth.account.usage.fetch'], + operationGrants: [ + 'host.status', + 'oauth.account.usage.fetch', + // Task Ledger was replaced by the SessionTodo authority. Keep + // the existing principal's equivalent read authority without + // requiring credential rotation during a Host update. + 'task.ledger.query', + ], canPublishClientCapabilities: false, canUseHostPaths: false, createdAt: '2026-01-01T00:00:00.000Z', @@ -1394,7 +1401,10 @@ test('releases a retired operation grant when opening an existing access file', ); const authority = await openRuntimeHostAccessAuthority(directory); - assert.deepEqual(authority.authenticate(credential)?.operationGrants, ['host.status']); + assert.deepEqual(authority.authenticate(credential)?.operationGrants, [ + 'host.status', + 'session.todo.query', + ]); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index 77da3af44a..d45860c692 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -47,6 +47,10 @@ const TURN_QUERY_REPLACEMENT_GRANTS = [ TURN_QUERY_GRANT, 'session.turn_landmarks.query', ] as const satisfies readonly OperationKey[]; +const LEGACY_TASK_LEDGER_QUERY_GRANT = 'task.ledger.query'; +const TASK_LEDGER_QUERY_REPLACEMENT_GRANTS = [ + 'session.todo.query', +] as const satisfies readonly OperationKey[]; // Operations that left the protocol entirely. A previously issued access file // may still grant them; the grant is released on decode — there is nothing to // migrate it to — because failing the whole file would keep the Host from @@ -450,7 +454,9 @@ function migrateStoredOperationGrants(grants: readonly string[]): readonly strin ? TRANSCRIPT_QUERY_REPLACEMENT_GRANTS : stored === TURN_QUERY_GRANT ? TURN_QUERY_REPLACEMENT_GRANTS - : [stored]; + : stored === LEGACY_TASK_LEDGER_QUERY_GRANT + ? TASK_LEDGER_QUERY_REPLACEMENT_GRANTS + : [stored]; for (const replacement of replacements) { if (seen.has(replacement)) continue; seen.add(replacement); From b3934dc7f45fba7c66d2274f845ef3c6508420f0 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 1 Sep 2026 12:59:58 +0800 Subject: [PATCH 2/2] fix(desktop): clarify Runtime Host recovery consent Generated-by: OpenAI Codex --- .../src/main/__tests__/native-diagnostic-dialog.test.ts | 1 + apps/desktop/src/main/native-diagnostic-dialog.ts | 4 ++-- apps/desktop/src/main/runtime-host-boot.ts | 7 ++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index cb70416103..a5d0d1fa92 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -176,6 +176,7 @@ test('managed Host recovery preserves the workspace and confirms active-work int 'Copy Diagnostics', ]); assert.match(shown?.detail ?? '', /workspace, Host identity, credentials, and settings/); + assert.match(shown?.detail ?? '', /automatic update compatibility cannot be confirmed/); assert.match(shown?.detail ?? '', /interrupt that work/); assert.match(shown?.detail ?? '', /service update failed/); }); diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 920dcacf7b..9e848805b7 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -237,7 +237,7 @@ const RUNTIME_HOST_STARTUP_RECOVERY_COPY = { title: 'Maka needs to repair Runtime Host', message: 'The Runtime Host for this workspace could not start.', detail: - 'Maka can repair the managed Runtime Host selected by this Desktop. Your workspace, Host identity, credentials, and settings will be preserved.', + 'Maka can repair the managed Runtime Host selected by this Desktop. Your workspace, Host identity, credentials, and settings will be preserved. Repair may replace the installed Host with the version selected for this Desktop even when automatic update compatibility cannot be confirmed.', activeTasks: 'The Host may still own active work. Continuing can interrupt that work before the Host restarts.', repairFailed: 'The previous repair attempt did not finish:', @@ -249,7 +249,7 @@ const RUNTIME_HOST_STARTUP_RECOVERY_COPY = { title: 'Maka 需要修复 Runtime Host', message: '管理此工作区的 Runtime Host 无法启动。', detail: - 'Maka 可以修复此 Desktop 选择的托管 Runtime Host。工作区、Host 身份、凭证和设置都会保留。', + 'Maka 可以修复此 Desktop 选择的托管 Runtime Host。工作区、Host 身份、凭证和设置都会保留。即使无法确认自动更新兼容性,修复也可能使用此 Desktop 选择的版本替换当前 Host。', activeTasks: 'Host 可能仍有正在运行的任务。继续会先中断这些任务,再重启 Host。', repairFailed: '上一次修复未能完成:', repair: '修复 Runtime Host', diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9c1de141f7..27621aaa2b 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -170,6 +170,7 @@ import { type RuntimeHostDesktopManager, } from "./runtime-host-desktop-manager.js"; import { + canRepairManagedRuntimeHostStartup, DesktopRuntimeHostStartupRecoveryCancelledError, startDesktopRuntimeHostWithRecovery, } from "./runtime-host-startup-recovery.js"; @@ -1078,7 +1079,11 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( resolveLocalHostReplacement: (registration, signal) => localRuntimeHostRemoteAccess.resolveConflictingHostReplacement(registration, signal), onFatalError: (error, target) => { - if (!runtimeHostManager && target.profile.kind === "local") return; + if ( + !runtimeHostManager && + target.profile.kind === "local" && + canRepairManagedRuntimeHostStartup(error) + ) return; if (error instanceof RuntimeHostUpgradeCancelledError) { if (target.profile.kind === "local") app.quit(); return;