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:36 UTC

(maka) branch fix/runtime-host-conflict-stop created (now 679e52c923)

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

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


      at 679e52c923 fix(desktop): replace conflicting local runtime host

This branch includes the following new commits:

     new 679e52c923 fix(desktop): replace conflicting local runtime host

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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


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

Posted by wy...@apache.org.
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