You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/29 14:50:37 UTC

(maka) 01/01: fix(desktop): replace conflicting local runtime host

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch fix/runtime-host-conflict-stop
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 679e52c923dc0e7c3bf8ae14d81355b0fbaab2bc
Author: Wang <m4...@gmail.com>
AuthorDate: Sat Aug 29 22:49:22 2026 +0800

    fix(desktop): replace conflicting local runtime host
    
    Generated-by: OpenAI Codex
---
 .../__tests__/runtime-host-desktop-manager.test.ts |  43 +++++-
 .../__tests__/runtime-host-local-operator.test.ts  |   1 -
 .../runtime-host-local-remote-access.test.ts       | 122 ++++++++++++++-
 .../__tests__/runtime-host-ssh-terminal.test.ts    |   5 +-
 .../__tests__/runtime-host-upgrade-dialog.test.ts  |  62 +++++++-
 apps/desktop/src/main/runtime-host-boot.ts         |   2 +
 .../src/main/runtime-host-desktop-manager.ts       |  35 ++++-
 .../src/main/runtime-host-local-operator.ts        |   5 +-
 .../src/main/runtime-host-local-remote-access.ts   | 172 ++++++++++++++++++++-
 apps/desktop/src/main/runtime-host-ssh-terminal.ts |   5 +-
 apps/desktop/src/main/runtime-host-upgrade-copy.ts |  48 ++++--
 .../src/main/runtime-host-upgrade-dialog.ts        |  20 ++-
 packages/cli/src/cli-core.ts                       |   5 +
 .../cli/src/runtime-host-lifecycle-transaction.ts  |  55 +++++--
 packages/cli/src/runtime-host-update-command.ts    |  17 +-
 15 files changed, 530 insertions(+), 67 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
index cbc687b668..c7fbce5547 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
@@ -1056,7 +1056,7 @@ test('restarts an idle generation-aware Host without prompting', async () => {
     },
     upgradePrompts: {
       restartable: async () => assert.fail('idle Host must not prompt before restart'),
-      waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+      nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
     },
   });
 
@@ -1077,7 +1077,7 @@ test('prompts before restarting a generation-aware Host with active work', async
         prompts += 1;
         return 'restart';
       },
-      waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+      nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
     },
   });
 
@@ -1097,7 +1097,7 @@ test('prompts before restarting a generation-aware Host with a residency', async
         prompts += 1;
         return 'restart';
       },
-      waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+      nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
     },
   });
 
@@ -1117,7 +1117,7 @@ test('prompts before restarting a generation-aware Host with connections', async
         prompts += 1;
         return 'restart';
       },
-      waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+      nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
     },
   });
 
@@ -1137,7 +1137,7 @@ test('prompts when a restartable Host has no activity snapshot', async () => {
         prompts += 1;
         return 'restart';
       },
-      waitOnly: async () => assert.fail('restartable conflict used wait-only prompt'),
+      nonRestartable: async () => assert.fail('restartable conflict used non-restartable prompt'),
     },
   });
 
@@ -1160,7 +1160,7 @@ test('waits passively for a Host that cannot be taken over', async () => {
     },
     upgradePrompts: {
       restartable: async () => assert.fail('wait-only conflict used restart prompt'),
-      waitOnly: async () => 'wait',
+      nonRestartable: async () => 'wait',
     },
     waitForHostRetirement: async (registration) => {
       assert.equal(registration.hostEpoch, conflict.registration.hostEpoch);
@@ -1175,6 +1175,35 @@ test('waits passively for a Host that cannot be taken over', async () => {
   await owner.close();
 });
 
+test('replaces a non-restartable Local Host through the supplied authority and retries', async () => {
+  const conflict = upgradeRequired(false);
+  const replacement = candidateHarness();
+  let starts = 0;
+  let replaced: typeof conflict.registration | undefined;
+  const owner = await startRuntimeHostDesktopManager(
+    {} as DesktopRuntimeHostCandidateStartInput,
+    {
+      startCandidate: async () => {
+        starts += 1;
+        return starts === 1 ? conflict : ready(replacement.candidate);
+      },
+      upgradePrompts: {
+        restartable: async () => assert.fail('non-restartable conflict used restart prompt'),
+        nonRestartable: async (_conflict, canReplace) => {
+          assert.equal(canReplace, true);
+          return 'replace';
+        },
+      },
+      replaceLocalHost: async (registration) => {
+        replaced = registration;
+      },
+    },
+  );
+  assert.equal(starts, 2);
+  assert.equal(replaced?.hostEpoch, conflict.registration.hostEpoch);
+  await owner.close();
+});
+
 test('lets the user cancel startup when an incompatible Host owns the root', async () => {
   const conflict = incompatibleHost('blocked_by_residency');
   let presented: DesktopRuntimeHostCandidateStartResult | undefined;
@@ -1183,7 +1212,7 @@ test('lets the user cancel startup when an incompatible Host owns the root', asy
       startCandidate: async () => conflict,
       upgradePrompts: {
         restartable: async () => assert.fail('incompatible Host used restart prompt'),
-        waitOnly: async (actual) => {
+        nonRestartable: async (actual) => {
           presented = actual;
           return 'cancel';
         },
diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts
index b4155e9947..b8ef7de8b8 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-local-operator.test.ts
@@ -192,7 +192,6 @@ test('local update runs the selected package against the exact managed deploymen
     'exec', '--yes', '--package', 'maka-agent@0.3.0', '--',
     'maka', 'runtime-host', 'service', 'update', '--framed',
     '--managed-root-id', 'a'.repeat(64),
-    '--operator-deployment-id', deploymentId,
     '--expected-service-id', 'a'.repeat(64),
     '--expected-root-path', '/tmp/maka/root',
     '--expected-root-id', 'a'.repeat(64),
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 40583de49e..b6c3fbc3a2 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
@@ -23,10 +23,19 @@ import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import test from 'node:test';
 import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client';
+import {
+  INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+  RUNTIME_HOST_COMPATIBILITY_EPOCH,
+  RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
+  type HostRegistration,
+} from '@maka/runtime-host/protocol';
 import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js';
 
 const RECOVERY_DEPLOYMENT_ID = '33333333-3333-4333-8333-333333333333';
-import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js';
+import {
+  createDesktopLocalRuntimeHostRemoteAccess,
+  stopConflictingEphemeralRuntimeHost,
+} from '../runtime-host-local-remote-access.js';
 import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js';
 
 test('enabling remote access hands the same root to one managed service before Desktop resumes', async (t) => {
@@ -228,6 +237,96 @@ test('keeps the managed service visible when Direct peer support is unavailable'
   assert.equal(snapshot.managedService, true);
 });
 
+test('replaces a conflicting supervised Host through its exact service authority', async (t) => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-local-managed-conflict-'));
+  t.after(() => rm(base, { recursive: true, force: true }));
+  const clientDataRoot = join(base, 'client');
+  const rootPath = join(clientDataRoot, 'workspaces', 'default');
+  const rootId = 'a'.repeat(64);
+  await mkdir(rootPath, { recursive: true });
+  await writeManagedLifecycle(clientDataRoot, rootPath, rootId);
+  let updated = false;
+  const service = createDesktopLocalRuntimeHostRemoteAccess({
+    ipcMain: { handle() {}, removeHandler() {} },
+    clientDataRoot,
+    rootPath,
+    rootId,
+    directPeerAvailable: true,
+    manager: () => undefined,
+    resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
+    operator: {
+      async runUpdate(input: {
+        readonly target: { readonly rootId: string; readonly deploymentId?: string };
+        readonly allowInterruptActiveTasks?: boolean;
+      }) {
+        assert.equal(input.target.rootId, rootId);
+        assert.equal(input.target.deploymentId, RECOVERY_DEPLOYMENT_ID);
+        assert.equal(input.allowInterruptActiveTasks, true);
+        updated = true;
+        return {
+          kind: 'result' as const,
+          action: 'update' as const,
+          update: { kind: 'updated', previousVersion: '0.2.0', targetVersion: '0.2.0' },
+        } as never;
+      },
+      async close() {},
+    } as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
+  });
+  t.after(() => service.close());
+
+  await service.replaceConflictingHost(
+    hostRegistration({ rootId, lifecycleMode: 'service' }),
+    new AbortController().signal,
+  );
+  assert.equal(updated, true);
+});
+
+test('stops only the revalidated ephemeral Host identity', async () => {
+  const registration = hostRegistration({ lifecycleMode: 'ephemeral' });
+  let alive = true;
+  const signaled: number[] = [];
+  await stopConflictingEphemeralRuntimeHost(
+    {
+      rootPath: '/workspace',
+      registration,
+      signal: new AbortController().signal,
+    },
+    {
+      connectExisting: async () => ({
+        kind: 'upgrade_required',
+        registration,
+        restartable: false,
+      }) as never,
+      signalProcess(pid) {
+        signaled.push(pid);
+        alive = false;
+      },
+      isProcessAlive: () => alive,
+    },
+  );
+  assert.deepEqual(signaled, [registration.pid]);
+
+  await assert.rejects(
+    stopConflictingEphemeralRuntimeHost(
+      {
+        rootPath: '/workspace',
+        registration,
+        signal: new AbortController().signal,
+      },
+      {
+        connectExisting: async () => ({
+          kind: 'upgrade_required',
+          registration: { ...registration, hostEpoch: 'replacement' },
+          restartable: false,
+        }) as never,
+        signalProcess: () => assert.fail('a changed Host identity must not be signaled'),
+        isProcessAlive: () => true,
+      },
+    ),
+    /could not verify that the process still owns this workspace/u,
+  );
+});
+
 test('does not persist recoverable setup authority before Desktop ownership commits', async (t) => {
   const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-ownership-'));
   t.after(() => rm(base, { recursive: true, force: true }));
@@ -738,6 +837,27 @@ async function writeManagedLifecycle(
   );
 }
 
+function hostRegistration(
+  overrides: Partial<Pick<HostRegistration, 'rootId' | 'lifecycleMode'>> = {},
+): HostRegistration {
+  return {
+    kind: 'maka-runtime-host',
+    schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
+    rootId: 'a'.repeat(64),
+    hostEpoch: 'older-host',
+    endpoint: '/tmp/runtime-host.sock',
+    protocolMin: 0,
+    protocolMax: 0,
+    compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
+    compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+    compositionRevision: 'legacy',
+    state: 'ready',
+    pid: 42,
+    createdAt: '2026-08-29T00:00:00.000Z',
+    ...overrides,
+  };
+}
+
 function sharedCredential(credentialId: string, status: 'active' | 'pending') {
   return {
     credentialId,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
index a7b183a614..30696f5461 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
@@ -462,10 +462,7 @@ test('runs an exact update package and reports progress before an active-work re
   assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u);
   assert.match(remoteCommand, /runtime-host.*service.*update/u);
   assert.match(remoteCommand, /--managed-root-id.*a{64}/u);
-  assert.match(
-    remoteCommand,
-    /--operator-deployment-id.*00000000-0000-4000-8000-000000000001/u,
-  );
+  assert.doesNotMatch(remoteCommand, /--operator-deployment-id/u);
   assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u);
   harness.pty.emitData('Password: ');
   harness.pty.emitData(
diff --git a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts
index 3f515e2c9b..d7e6bb8037 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-upgrade-dialog.test.ts
@@ -20,6 +20,7 @@
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
 import { buildRuntimeHostUpgradeDialogOptions } from '../runtime-host-upgrade-copy.js';
+import { createRuntimeHostUpgradePrompts } from '../runtime-host-upgrade-dialog.js';
 
 const conflict = {
   kind: 'upgrade_required',
@@ -39,8 +40,8 @@ const conflict = {
 } as never;
 
 test('localizes upgrade activity without changing decision indexes', () => {
-  const en = buildRuntimeHostUpgradeDialogOptions(conflict, true, 'en');
-  const zh = buildRuntimeHostUpgradeDialogOptions(conflict, true, 'zh');
+  const en = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'en');
+  const zh = buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', 'zh');
   assert.deepEqual(en.buttons, ['Restart Runtime Host', 'Wait', 'Cancel Startup']);
   assert.deepEqual(zh.buttons, ['重启 Runtime Host', '等待', '取消启动']);
   assert.equal(en.defaultId, 1);
@@ -49,4 +50,61 @@ test('localizes upgrade activity without changing decision indexes', () => {
   assert.match(zh.detail ?? '', /每日回顾: 1/);
   assert.match(en.detail ?? '', /Scheduled Task: 2/);
   assert.match(zh.detail ?? '', /计划任务: 2/);
+  assert.match(en.detail ?? '', /Process ID \(PID\):/);
+});
+
+test('offers a non-default replacement action for a non-restartable Local Host', () => {
+  const options = buildRuntimeHostUpgradeDialogOptions(
+    {
+      kind: 'upgrade_required',
+      restartable: false,
+      registration: { pid: 42 },
+    } as never,
+    'replace',
+    'en',
+  );
+  assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']);
+  assert.equal(options.defaultId, 1);
+  assert.equal(options.cancelId, 2);
+  assert.match(options.detail ?? '', /Maka will stop this Host/);
+});
+
+test('maps the native replacement button to the replace decision', async () => {
+  const prompts = createRuntimeHostUpgradePrompts(
+    async () => 'en',
+    async (options) => {
+      assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Wait', 'Cancel Startup']);
+      return { response: 0, checkboxChecked: false };
+    },
+  );
+  assert.equal(
+    await prompts.nonRestartable(
+      {
+        kind: 'upgrade_required',
+        restartable: false,
+        registration: { pid: 42 },
+      } as never,
+      true,
+    ),
+    'replace',
+  );
+});
+
+test('does not offer passive waiting for a supervised Host', async () => {
+  const conflict = {
+    kind: 'upgrade_required',
+    restartable: false,
+    registration: { pid: 42, lifecycleMode: 'service' },
+  } as never;
+  const prompts = createRuntimeHostUpgradePrompts(
+    async () => 'en',
+    async (options) => {
+      assert.deepEqual(options.buttons, ['Stop Host and Continue', 'Cancel Startup']);
+      assert.equal(options.defaultId, 1);
+      assert.equal(options.cancelId, 1);
+      assert.doesNotMatch(options.detail ?? '', /If you wait/u);
+      return { response: 1, checkboxChecked: false };
+    },
+  );
+  assert.equal(await prompts.nonRestartable(conflict, true), 'cancel');
 });
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 4dd1639eab..9c9cf0a7b3 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -1013,6 +1013,8 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
       });
     },
     recoverLocalHost: (signal) => localRuntimeHostRemoteAccess.recoverBeforeLocalHostStart(signal),
+    replaceLocalHost: (registration, signal) =>
+      localRuntimeHostRemoteAccess.replaceConflictingHost(registration, signal),
     onFatalError: (error, target) => {
       if (error instanceof RuntimeHostUpgradeCancelledError) {
         if (target.profile.kind === "local") app.quit();
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index 474a0d872c..7c38d06c06 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -136,7 +136,7 @@ export class DesktopLocalHostRetirementError extends Error {
 }
 
 export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel';
-export type RuntimeHostWaitDecision = 'wait' | 'cancel';
+export type RuntimeHostNonRestartableDecision = 'replace' | 'wait' | 'cancel';
 
 export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconnectError {
   constructor() {
@@ -170,7 +170,10 @@ export interface RuntimeHostUpgradePrompts {
   restartable(
     conflict: RuntimeHostRestartableConflict,
   ): Promise<RuntimeHostRestartDecision>;
-  waitOnly(conflict: RuntimeHostWaitConflict): Promise<RuntimeHostWaitDecision>;
+  nonRestartable(
+    conflict: RuntimeHostWaitConflict,
+    canReplace: boolean,
+  ): Promise<RuntimeHostNonRestartableDecision>;
 }
 
 interface DesktopRuntimeHostTargetGeneration {
@@ -210,6 +213,10 @@ export async function startRuntimeHostDesktopManager(
       registration: HostRegistration,
       signal: AbortSignal,
     ) => Promise<void>;
+    replaceLocalHost?: (
+      registration: HostRegistration,
+      signal: AbortSignal,
+    ) => Promise<void>;
     recoverLocalHost?: (signal: AbortSignal) => Promise<boolean>;
     reconnectBackoff?: RuntimeHostReconnectBackoff;
     pairingFinalizationTimeoutMs?: number;
@@ -226,6 +233,7 @@ export async function startRuntimeHostDesktopManager(
     options.upgradePrompts,
     options.waitForHostExit ?? waitForProcessExit,
     options.waitForHostRetirement ?? waitForProcessRetirement,
+    options.replaceLocalHost,
     options.recoverLocalHost,
     options.reconnectBackoff,
     options.pairingFinalizationTimeoutMs ?? DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS,
@@ -266,6 +274,9 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
       registration: HostRegistration,
       signal: AbortSignal,
     ) => Promise<void>,
+    private readonly replaceLocalHost:
+      | ((registration: HostRegistration, signal: AbortSignal) => Promise<void>)
+      | undefined,
     private readonly recoverLocalHost:
       | ((signal: AbortSignal) => Promise<boolean>)
       | undefined,
@@ -853,8 +864,19 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
         result.kind === 'incompatible' ||
         (result.kind === 'upgrade_required' && !result.restartable)
       ) {
-        const decision = await this.#resolveWaitOnly(result);
+        const canReplace = !target.input.profileTarget && this.replaceLocalHost !== undefined;
+        const decision = await this.#resolveNonRestartable(result, canReplace);
         if (decision === 'cancel') throw new RuntimeHostUpgradeCancelledError();
+        if (decision === 'replace') {
+          if (!canReplace || !this.replaceLocalHost) {
+            throw new RuntimeHostPermanentReconnectError(
+              'This Runtime Host cannot be replaced from the current target',
+            );
+          }
+          await this.replaceLocalHost(result.registration, signal);
+          takeoverHostEpoch = undefined;
+          continue;
+        }
         takeoverHostEpoch = undefined;
         await this.waitForHostRetirement(result.registration, signal);
         continue;
@@ -871,8 +893,11 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     return this.#missingUpgradePrompt();
   }
 
-  #resolveWaitOnly(conflict: RuntimeHostWaitConflict): Promise<RuntimeHostWaitDecision> {
-    if (this.upgradePrompts) return this.upgradePrompts.waitOnly(conflict);
+  #resolveNonRestartable(
+    conflict: RuntimeHostWaitConflict,
+    canReplace: boolean,
+  ): Promise<RuntimeHostNonRestartableDecision> {
+    if (this.upgradePrompts) return this.upgradePrompts.nonRestartable(conflict, canReplace);
     return this.#missingUpgradePrompt();
   }
 
diff --git a/apps/desktop/src/main/runtime-host-local-operator.ts b/apps/desktop/src/main/runtime-host-local-operator.ts
index e227c2dfe5..e8c6e1f582 100644
--- a/apps/desktop/src/main/runtime-host-local-operator.ts
+++ b/apps/desktop/src/main/runtime-host-local-operator.ts
@@ -346,8 +346,7 @@ export function createDesktopRuntimeHostLocalOperator(input: {
     },
     runUpdate(command, onProgress) {
       if (closed) throw new Error('Local Runtime Host operator is closed');
-      const deploymentId = command.target.deploymentId;
-      if (!deploymentId) {
+      if (!command.target.deploymentId) {
         return Promise.reject(new Error('Runtime Host update requires a deployment generation'));
       }
       const setupPackage = resolveLocalSetupPackage(command.setupPackage);
@@ -367,8 +366,6 @@ export function createDesktopRuntimeHostLocalOperator(input: {
             '--framed',
             '--managed-root-id',
             command.target.rootId,
-            '--operator-deployment-id',
-            deploymentId,
             ...managedTargetArgs(command.target),
             ...(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 275aacde91..d763e5b001 100644
--- a/apps/desktop/src/main/runtime-host-local-remote-access.ts
+++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts
@@ -23,11 +23,17 @@ import { hostname } from 'node:os';
 import { dirname, isAbsolute, join } from 'node:path';
 import type { IpcMain } from 'electron';
 import {
+  connectExistingRuntimeHost,
   consumeAccessCredentialDelivery,
   encodeRuntimeHostOwnerConnectionCode,
 } from '@maka/runtime-host/client';
 import { resolveRuntimeHostManagedDeploymentAuthority } from '@maka/runtime-host/operator';
-import { REMOTE_OWNER_OPERATION_GRANTS } from '@maka/runtime-host/protocol';
+import {
+  INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+  REMOTE_OWNER_OPERATION_GRANTS,
+  RUNTIME_HOST_PROTOCOL_VERSION,
+  type HostRegistration,
+} from '@maka/runtime-host/protocol';
 import type {
   DesktopLocalRuntimeHostRemoteAccessEnableResult,
   DesktopLocalRuntimeHostRemoteAccessSnapshot,
@@ -100,6 +106,7 @@ export interface DesktopLocalRuntimeHostRemoteAccess {
   changeManaged<T>(
     operation: (target: DesktopRuntimeHostLocalManagementTarget) => Promise<T>,
   ): Promise<T>;
+  replaceConflictingHost(registration: HostRegistration, signal: AbortSignal): Promise<void>;
   recoverBeforeLocalHostStart(signal?: AbortSignal): Promise<boolean>;
   recover(): Promise<void>;
   close(): Promise<void>;
@@ -152,6 +159,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
   readonly resolveManagedDeploymentAuthority?: (
     rootId: string,
   ) => Promise<LocalManagedDeploymentAuthority | undefined>;
+  readonly replaceEphemeralHost?: (
+    registration: HostRegistration,
+    signal: AbortSignal,
+  ) => Promise<void>;
 }): DesktopLocalRuntimeHostRemoteAccess {
   const lifecyclePath = join(input.clientDataRoot, LIFECYCLE_FILE);
   const closing = new AbortController();
@@ -663,6 +674,50 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
         const managed = requireManagementTarget(lifecycle);
         return requireManager(input.manager).runManagedLocalHostChange(() => operation(managed));
       }),
+    replaceConflictingHost: (registration, signal) =>
+      serialize(async () => {
+        signal.throwIfAborted();
+        if (registration.rootId !== input.rootId) {
+          throw conflictReplacementError(registration.pid, 'the workspace identity changed');
+        }
+        if (registration.lifecycleMode !== 'service') {
+          await (input.replaceEphemeralHost ?? ((expected, operationSignal) =>
+            stopConflictingEphemeralRuntimeHost({
+              rootPath: input.rootPath,
+              registration: expected,
+              signal: operationSignal,
+            })))(registration, signal);
+          return;
+        }
+        const managed = requireManagementTarget(
+          await readLifecycle(lifecyclePath, input.rootPath, input.rootId),
+        );
+        const setupPackage = await input.resolveSetupPackage(signal);
+        const frame = await input.operator.runUpdate(
+          {
+            setupPackage,
+            target: managed,
+            allowInterruptActiveTasks: true,
+            signal,
+          },
+          () => undefined,
+        );
+        if (frame.kind === 'error') {
+          throw conflictReplacementError(registration.pid, frame.error.message);
+        }
+        if (frame.kind === 'progress' || frame.action !== 'update') {
+          throw conflictReplacementError(
+            registration.pid,
+            'the managed service returned an unrelated result',
+          );
+        }
+        if (frame.update.kind === 'active_tasks') {
+          throw conflictReplacementError(
+            registration.pid,
+            'the managed service refused to interrupt active work',
+          );
+        }
+      }),
     recoverBeforeLocalHostStart: async (signal) => {
       const operationSignal = signal ? AbortSignal.any([signal, closing.signal]) : closing.signal;
       operationSignal.throwIfAborted();
@@ -735,6 +790,121 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
   };
 }
 
+export async function stopConflictingEphemeralRuntimeHost(
+  input: {
+    readonly rootPath: string;
+    readonly registration: HostRegistration;
+    readonly signal: AbortSignal;
+  },
+  dependencies: {
+    readonly connectExisting?: typeof connectExistingRuntimeHost;
+    readonly signalProcess?: (pid: number) => void;
+    readonly isProcessAlive?: (pid: number) => boolean;
+    readonly wait?: (ms: number, signal: AbortSignal) => Promise<void>;
+  } = {},
+): Promise<void> {
+  if (input.registration.lifecycleMode === 'service') {
+    throw conflictReplacementError(
+      input.registration.pid,
+      'a system-supervised Host must be replaced through its service operator',
+    );
+  }
+  input.signal.throwIfAborted();
+  const current = await (dependencies.connectExisting ?? connectExistingRuntimeHost)({
+    rootPath: input.rootPath,
+    protocol: {
+      min: RUNTIME_HOST_PROTOCOL_VERSION,
+      max: RUNTIME_HOST_PROTOCOL_VERSION,
+    },
+    compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+  });
+  if (current.kind === 'connected') {
+    await current.connection.close();
+    return;
+  }
+  const currentRegistration = 'registration' in current ? current.registration : undefined;
+  const isAlive = dependencies.isProcessAlive ?? processIsAlive;
+  if (!sameHostRegistration(currentRegistration, input.registration)) {
+    if (!isAlive(input.registration.pid)) return;
+    throw conflictReplacementError(
+      input.registration.pid,
+      'Maka could not verify that the process still owns this workspace',
+    );
+  }
+  if (current.kind === 'unavailable') {
+    throw conflictReplacementError(
+      input.registration.pid,
+      'Maka could not verify the Runtime Host endpoint',
+    );
+  }
+  input.signal.throwIfAborted();
+  try {
+    (dependencies.signalProcess ?? signalRuntimeHostProcess)(input.registration.pid);
+  } catch (error) {
+    if (!isMissingProcessError(error)) throw error;
+    return;
+  }
+  const wait = dependencies.wait ?? waitForAbortableDelay;
+  const deadline = Date.now() + 10_000;
+  while (isAlive(input.registration.pid)) {
+    if (Date.now() >= deadline) {
+      throw conflictReplacementError(
+        input.registration.pid,
+        'the process did not exit after Maka requested a graceful stop',
+      );
+    }
+    await wait(100, input.signal);
+  }
+}
+
+function sameHostRegistration(
+  current: HostRegistration | undefined,
+  expected: HostRegistration,
+): boolean {
+  return current?.rootId === expected.rootId &&
+    current.hostEpoch === expected.hostEpoch &&
+    current.pid === expected.pid &&
+    current.endpoint === expected.endpoint;
+}
+
+function signalRuntimeHostProcess(pid: number): void {
+  process.kill(pid, 'SIGTERM');
+}
+
+function processIsAlive(pid: number): boolean {
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch (error) {
+    return !isMissingProcessError(error);
+  }
+}
+
+function isMissingProcessError(error: unknown): boolean {
+  return error instanceof Error &&
+    'code' in error &&
+    (error as NodeJS.ErrnoException).code === 'ESRCH';
+}
+
+function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
+  signal.throwIfAborted();
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(() => {
+      signal.removeEventListener('abort', onAbort);
+      resolve();
+    }, ms);
+    const onAbort = () => {
+      clearTimeout(timer);
+      reject(signal.reason);
+    };
+    signal.addEventListener('abort', onAbort, { once: true });
+  });
+}
+
+function conflictReplacementError(pid: number, reason: string): Error {
+  return new Error(`Maka could not replace Runtime Host process ${pid}: ${reason}`);
+}
+
 function supported(directPeerAvailable: boolean): boolean {
   return directPeerAvailable && (process.platform === 'darwin' || process.platform === 'linux');
 }
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index 8222ce8e9d..8698dcf9c9 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -1244,8 +1244,7 @@ function runtimeHostUpdateRemoteCommand(
   setupPackage: PreparedSetupPackage,
   input: DesktopRuntimeHostSshUpdateInput,
 ): string {
-  const deploymentId = input.expectedTarget.deploymentId;
-  if (!deploymentId) {
+  if (!input.expectedTarget.deploymentId) {
     throw new Error('Runtime Host update requires a deployment generation');
   }
   return runtimeHostPackageRemoteCommand(
@@ -1257,8 +1256,6 @@ function runtimeHostUpdateRemoteCommand(
       '--framed',
       '--managed-root-id',
       input.expectedTarget.rootId,
-      '--operator-deployment-id',
-      deploymentId,
       ...managedServiceTargetArgs(input.expectedTarget),
       ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
     ],
diff --git a/apps/desktop/src/main/runtime-host-upgrade-copy.ts b/apps/desktop/src/main/runtime-host-upgrade-copy.ts
index 4a4a46c99a..c00195729b 100644
--- a/apps/desktop/src/main/runtime-host-upgrade-copy.ts
+++ b/apps/desktop/src/main/runtime-host-upgrade-copy.ts
@@ -36,30 +36,42 @@ type ActivityKey =
 
 export function buildRuntimeHostUpgradeDialogOptions(
   conflict: Conflict,
-  canRestart: boolean,
+  action: 'restart' | 'replace' | undefined,
   locale: UiLocale,
 ): MessageBoxOptions {
   const activity = conflict.handshake?.activity;
   const hasWork =
     (activity?.activeOperations ?? 0) > 0 || (activity?.residencies.length ?? 0) > 0;
   const copy = UPGRADE_COPY[locale];
-  const buttons = canRestart ? [copy.restart, copy.wait, copy.cancel] : [copy.wait, copy.cancel];
+  const canWait = conflict.registration.lifecycleMode !== 'service';
+  const buttons = action
+    ? canWait
+      ? [action === 'restart' ? copy.restart : copy.replace, copy.wait, copy.cancel]
+      : [action === 'restart' ? copy.restart : copy.replace, copy.cancel]
+    : canWait
+      ? [copy.wait, copy.cancel]
+      : [copy.cancel];
   return {
     type: 'warning',
     title: copy.title,
     message: copy.message,
-    detail: formatActivity(conflict, locale),
+    detail: formatActivity(conflict, action, locale),
     buttons,
-    defaultId: hasWork || !canRestart ? (canRestart ? 1 : 0) : 0,
-    cancelId: canRestart ? 2 : 1,
+    defaultId: action === 'restart' && !hasWork ? 0 : action ? 1 : 0,
+    cancelId: action ? (canWait ? 2 : 1) : canWait ? 1 : 0,
     noLink: true,
   };
 }
 
-function formatActivity(conflict: Conflict, locale: UiLocale): string {
+function formatActivity(
+  conflict: Conflict,
+  action: 'restart' | 'replace' | undefined,
+  locale: UiLocale,
+): string {
   const activity = conflict.handshake?.activity;
   const copy = UPGRADE_COPY[locale];
   const lines: string[] = [];
+  lines.push(copy.processId(conflict.registration.pid));
   if (activity) {
     const minutes = Math.max(1, Math.round(activity.processUptimeSeconds / 60));
     lines.push(copy.uptime(minutes));
@@ -69,9 +81,12 @@ function formatActivity(conflict: Conflict, locale: UiLocale): string {
       lines.push(`${copy.activity[activityKey(residency.label)]}: ${residency.count}`);
     }
   } else lines.push(copy.unknownActivity);
-  lines.push('', copy.restartWarning);
-  if (conflict.kind !== 'upgrade_required' || !conflict.restartable) lines.push(copy.exitOwner);
-  lines.push(copy.waitExplanation);
+  lines.push('', action === 'replace' ? copy.replaceWarning : copy.restartWarning);
+  if (action === 'replace') lines.push(copy.replaceExplanation);
+  else if (conflict.kind !== 'upgrade_required' || !conflict.restartable) {
+    lines.push(copy.exitOwner(conflict.registration.pid));
+  }
+  if (conflict.registration.lifecycleMode !== 'service') lines.push(copy.waitExplanation);
   return lines.join('\n');
 }
 
@@ -93,15 +108,21 @@ const UPGRADE_COPY = {
     title: 'Older Runtime Host is running',
     message: 'Another Runtime Host process still owns this workspace.',
     restart: 'Restart Runtime Host',
+    replace: 'Stop Host and Continue',
     wait: 'Wait',
     cancel: 'Cancel Startup',
     uptime: (n: number) => `Running for about ${n} ${n === 1 ? 'minute' : 'minutes'}`,
     connections: (n: number) => `${n} other client(s) are still connected`,
     operations: (n: number) => `${n} operation(s) are running`,
     unknownActivity: 'This Host version cannot report its background activity.',
+    processId: (pid: number) => `Process ID (PID): ${pid}`,
     restartWarning:
       'Restarting preserves durable state, but it can interrupt in-flight external work.',
-    exitOwner: 'Exit the process that owns this Host to replace it safely.',
+    replaceWarning:
+      'Stopping preserves durable state, but it can interrupt in-flight external work.',
+    replaceExplanation: 'Maka will stop this Host, replace it safely, and continue startup.',
+    exitOwner: (pid: number) =>
+      `End process ${pid} with your system process manager to replace this Host safely.`,
     waitExplanation: 'If you wait, Maka will continue automatically when this Host exits.',
     activity: {
       goal: 'Goal', scheduledTask: 'Scheduled Task', dailyReview: 'Daily Review',
@@ -113,14 +134,19 @@ const UPGRADE_COPY = {
     title: '旧版 Runtime Host 正在运行',
     message: '另一个 Runtime Host 进程仍占用此工作区。',
     restart: '重启 Runtime Host',
+    replace: '停止 Host 并继续',
     wait: '等待',
     cancel: '取消启动',
     uptime: (n: number) => `已运行约 ${n} 分钟`,
     connections: (n: number) => `仍有 ${n} 个其他客户端连接`,
     operations: (n: number) => `有 ${n} 个操作正在运行`,
     unknownActivity: '此 Host 版本无法报告后台活动。',
+    processId: (pid: number) => `进程 ID (PID):${pid}`,
     restartWarning: '重启会保留持久化状态,但可能中断正在进行的外部工作。',
-    exitOwner: '请退出当前占用此 Host 的进程,以便安全替换。',
+    replaceWarning: '停止 Host 会保留持久化状态,但可能中断正在进行的外部工作。',
+    replaceExplanation: 'Maka 将停止并安全替换此 Host,然后继续启动。',
+    exitOwner: (pid: number) =>
+      `请使用系统进程管理工具结束进程 ${pid},以便安全替换此 Host。`,
     waitExplanation: '若选择等待,当前 Host 退出后 Maka 将自动继续。',
     activity: {
       goal: '目标', scheduledTask: '计划任务', dailyReview: '每日回顾', execution: '活动执行',
diff --git a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts
index b051eabc9e..68e08bf3ec 100644
--- a/apps/desktop/src/main/runtime-host-upgrade-dialog.ts
+++ b/apps/desktop/src/main/runtime-host-upgrade-dialog.ts
@@ -22,7 +22,7 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron';
 import type {
   RuntimeHostRestartDecision,
   RuntimeHostUpgradePrompts,
-  RuntimeHostWaitDecision,
+  RuntimeHostNonRestartableDecision,
 } from './runtime-host-desktop-manager.js';
 import { buildRuntimeHostUpgradeDialogOptions } from './runtime-host-upgrade-copy.js';
 
@@ -36,21 +36,29 @@ export function createRuntimeHostUpgradePrompts(
   return {
     restartable: async (conflict): Promise<RuntimeHostRestartDecision> => {
       const locale = await resolveLocale();
+      const canWait = conflict.registration.lifecycleMode !== 'service';
       const { response } = await showDialog(
-        buildRuntimeHostUpgradeDialogOptions(conflict, true, locale),
+        buildRuntimeHostUpgradeDialogOptions(conflict, 'restart', locale),
         locale,
       );
       if (response === 0) return 'restart';
-      if (response === 1) return 'wait';
+      if (canWait && response === 1) return 'wait';
       return 'cancel';
     },
-    waitOnly: async (conflict): Promise<RuntimeHostWaitDecision> => {
+    nonRestartable: async (
+      conflict,
+      canReplace,
+    ): Promise<RuntimeHostNonRestartableDecision> => {
       const locale = await resolveLocale();
+      const canWait = conflict.registration.lifecycleMode !== 'service';
       const { response } = await showDialog(
-        buildRuntimeHostUpgradeDialogOptions(conflict, false, locale),
+        buildRuntimeHostUpgradeDialogOptions(conflict, canReplace ? 'replace' : undefined, locale),
         locale,
       );
-      return response === 0 ? 'wait' : 'cancel';
+      if (!canReplace) return canWait && response === 0 ? 'wait' : 'cancel';
+      if (response === 0) return 'replace';
+      if (canWait && response === 1) return 'wait';
+      return 'cancel';
     },
   };
 }
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index 0dd8e4c268..085ecc51d6 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -510,6 +510,10 @@ export async function runMakaCli(
     case 'runtime-host-service-update': {
       const { runManagedRuntimeHostSelectedUpdateCli, runManagedRuntimeHostUpdateCli } =
         await import('./runtime-host-update-command.js');
+      const { RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV } = await import(
+        '@maka/runtime-host/operator'
+      );
+      const sourcePackageIntegrity = process.env[RUNTIME_HOST_SETUP_SOURCE_PACKAGE_INTEGRITY_ENV];
       const serviceDataRoots = command.clientDataRoot
         ? deriveMakaDataRoots(command.clientDataRoot)
         : dataRoots;
@@ -534,6 +538,7 @@ export async function runMakaCli(
         clientDataRoot: serviceDataRoots.clientDataRoot,
         defaultRootPath: serviceDataRoots.workspaceRoot,
         sourcePackageRoot: fileURLToPath(new URL('..', import.meta.url)),
+        ...(sourcePackageIntegrity ? { sourcePackageIntegrity } : {}),
         version,
         expectedTarget: command.expectedTarget,
         ...(command.managedRootId ? { managedRootId: command.managedRootId } : {}),
diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts
index 69802ed8f1..aa9d48e42f 100644
--- a/packages/cli/src/runtime-host-lifecycle-transaction.ts
+++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts
@@ -338,6 +338,13 @@ export async function retireRuntimeHostLifecycleOwner(input: {
     },
   });
   if (connected.kind !== 'connected') {
+    if (input.allowInterruptActiveTasks && input.supervisor) {
+      const status = await input.supervisor.status();
+      if (status.active && status.pid !== null) {
+        await input.supervisor.retire();
+        return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000);
+      }
+    }
     throw new RuntimeHostLifecycleTransactionError(
       'transition_failed',
       `Runtime Host cannot prepare for retirement: ${connected.kind}`,
@@ -370,7 +377,14 @@ export async function retireRuntimeHostLifecycleOwner(input: {
   } finally {
     await connected.connection.close().catch(() => undefined);
   }
-  const deadline = Date.now() + (input.timeoutMs ?? 45_000);
+  return waitForRuntimeHostLifecycleOwner(capability, input.timeoutMs ?? 45_000);
+}
+
+async function waitForRuntimeHostLifecycleOwner(
+  capability: Awaited<ReturnType<typeof resolveExistingStorageRoot>>,
+  timeoutMs: number,
+): Promise<Extract<RuntimeHostLifecycleRetirement, { readonly kind: 'retired' }>> {
+  const deadline = Date.now() + timeoutMs;
   while (Date.now() < deadline) {
     const owner = await tryAcquireStateRootOwner(capability);
     if (owner) return { kind: 'retired', owner };
@@ -646,23 +660,9 @@ export async function verifyRuntimeHostLifecycleReady(
   timeoutMs = 45_000,
 ): Promise<void> {
   const canonical = decodeRuntimeHostManagedDeploymentConfig(config);
-  await deps.verifyOperator(canonical);
+  await verifyRuntimeHostLifecycleProjection(canonical, deps);
   if (canonical.lifecycle.mode !== 'supervised') return;
   const provider = deps.resolveProvider(canonical.lifecycle.provider);
-  const supervisorDefinition = runtimeHostSupervisorDefinition(canonical);
-  await provider.supervisor.verify(supervisorDefinition);
-  if (canonical.reconciliation.trigger === 'scheduled') {
-    await provider.reconciliationTrigger.verify(
-      runtimeHostReconciliationTriggerDefinition(canonical),
-    );
-    const trigger = await provider.reconciliationTrigger.status();
-    if (!trigger.installed || !trigger.active) {
-      throw new RuntimeHostLifecycleTransactionError(
-        'transition_failed',
-        'Runtime Host reconciliation scheduling is not active',
-      );
-    }
-  }
   const deadline = Date.now() + timeoutMs;
   let lastFailure: unknown = new Error('Runtime Host is not ready');
   while (Date.now() < deadline) {
@@ -703,6 +703,29 @@ export async function verifyRuntimeHostLifecycleReady(
   );
 }
 
+/** Verifies the durable operator and supervisor projection without requiring a compatible Host. */
+export async function verifyRuntimeHostLifecycleProjection(
+  config: RuntimeHostManagedDeploymentConfig,
+  deps: RuntimeHostLifecycleTransactionDeps,
+): Promise<void> {
+  const canonical = decodeRuntimeHostManagedDeploymentConfig(config);
+  await deps.verifyOperator(canonical);
+  if (canonical.lifecycle.mode !== 'supervised') return;
+  const provider = deps.resolveProvider(canonical.lifecycle.provider);
+  await provider.supervisor.verify(runtimeHostSupervisorDefinition(canonical));
+  if (canonical.reconciliation.trigger !== 'scheduled') return;
+  await provider.reconciliationTrigger.verify(
+    runtimeHostReconciliationTriggerDefinition(canonical),
+  );
+  const trigger = await provider.reconciliationTrigger.status();
+  if (!trigger.installed || !trigger.active) {
+    throw new RuntimeHostLifecycleTransactionError(
+      'transition_failed',
+      'Runtime Host reconciliation scheduling is not active',
+    );
+  }
+}
+
 export function runtimeHostSupervisorDefinition(
   config: RuntimeHostManagedDeploymentConfig,
 ): RuntimeHostProviderDefinition {
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
index f720426afd..a920aa0709 100644
--- a/packages/cli/src/runtime-host-update-command.ts
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -79,6 +79,7 @@ import {
   canDiscardRuntimeHostLifecycleDesiredArtifacts,
   replaceRuntimeHostLifecycle,
   resolveRecoverableRuntimeHostManagedDeployment,
+  verifyRuntimeHostLifecycleProjection,
   type RuntimeHostLifecycleTransactionDeps,
 } from './runtime-host-lifecycle-transaction.js';
 import { manageRuntimeHostManagedLifecycle } from './runtime-host-managed-lifecycle-manager.js';
@@ -92,6 +93,7 @@ export interface RuntimeHostUpdateCliOptions {
   readonly clientDataRoot: string;
   readonly defaultRootPath: string;
   readonly sourcePackageRoot: string;
+  readonly sourcePackageIntegrity?: string;
   readonly version: string;
   readonly expectedTarget: RuntimeHostManagedServiceTarget;
   readonly managedRootId?: string;
@@ -248,7 +250,7 @@ export async function runManagedRuntimeHostUpdateCli(
         const targetCliPath = resolveRuntimeHostManagedPackageCliPath(
           deploymentRoot,
           options.version,
-          options.registrySelection?.integrity,
+          options.sourcePackageIntegrity ?? options.registrySelection?.integrity,
         );
         const expectedCurrent = options.registrySelection?.current;
         const selectedDeploymentStillCurrent =
@@ -328,8 +330,11 @@ export async function runManagedRuntimeHostUpdateCli(
                 clientDataRoot: options.clientDataRoot,
                 sourcePackageRoot: options.sourcePackageRoot,
                 version: options.version,
-                ...(options.registrySelection
-                  ? { packageIntegrity: options.registrySelection.integrity }
+                ...((options.sourcePackageIntegrity ?? options.registrySelection?.integrity)
+                  ? {
+                      packageIntegrity:
+                        options.sourcePackageIntegrity ?? options.registrySelection?.integrity,
+                    }
                   : {}),
               }),
         );
@@ -579,7 +584,7 @@ async function runCanonicalRuntimeHostUpdate(
         const recovered = await resolveRecoverableRuntimeHostManagedDeployment(
           options.managedRootId,
           lifecycleDeps,
-          { expectedTarget: options.expectedTarget, ensureAvailable: true },
+          { expectedTarget: options.expectedTarget },
         );
         if (recovered.kind === 'absent') {
           throw new RuntimeHostServiceManagerError(
@@ -588,6 +593,7 @@ async function runCanonicalRuntimeHostUpdate(
           );
         }
         const current = recovered.config;
+        await verifyRuntimeHostLifecycleProjection(current, lifecycleDeps);
         assertRuntimeHostManagedOperatorConfig(
           current,
           options.operatorDeploymentId,
@@ -606,6 +612,7 @@ async function runCanonicalRuntimeHostUpdate(
           { resolveProvider: resolveRuntimeHostLifecycleProvider },
         );
         const targetIntegrity =
+          options.sourcePackageIntegrity ??
           options.registrySelection?.integrity ??
           (options.version === current.launch.package.version
             ? current.launch.package.integrity
@@ -613,7 +620,7 @@ async function runCanonicalRuntimeHostUpdate(
         if (!targetIntegrity) {
           throw new RuntimeHostServiceManagerError(
             'invalid_launch',
-            'An exact registry package identity is required for a managed update',
+            'An exact package identity is required for a managed update',
           );
         }
         const expectedCurrent = options.registrySelection?.current;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org