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/23 13:29:57 UTC

(maka) branch feat/managed-runtime-host-manual-update updated (a9817b496 -> d8c164dab)

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

M4n5ter pushed a change to branch feat/managed-runtime-host-manual-update
in repository https://gitbox.apache.org/repos/asf/maka.git


    omit a9817b496 fix(runtime-host): make update recovery authoritative
    omit 2d969f36c fix(runtime-host): make managed updates converge
    omit a65d507ac feat(runtime-host): add safe managed Host updates
     add ccdb5aaa9 test(storage): stop leaking control directories into the user cache (#3593)
     add 1ecd9bd6d test(windows): verify packaged sandbox client cancellation (#3586)
     add fe0ed2269 fix(storage): clear obsolete onboarding intent (#3571)
     add 574c1f976 fix(runtime): give a late steer an injection point in the turn it was aimed at (#3533)
     add a2f2a1afa test(runtime-host): remove flaky registration state assertion (#3580)
     add efddab2fb fix(ui): localize client capability recovery (#3588)
     add d4465dbfa chore(release): set product version to 0.2.0 (#3599)
     add 141f1be3c fix(ui): shorten default thinking label (#3606)
     add 1ffed8649 fix: close source release legal provenance gaps (#3565)
     add 2a545af0a fix(runtime-host): bind retirement to one service generation (#3600)
     add cb73143a7 fix(desktop): align sidebar chrome actions (#3609)
     add 724e7c012 fix(win): invalidate stale rollback backup authority (#3597)
     add 86bb419fc fix(runtime): classify provider capacity errors (#3365)
     add 6b48ed36f fix(runtime-host): reject incomplete successor identity (#3602)
     new 992c61748 feat(runtime-host): add safe managed Host updates
     new b665c4a32 fix(runtime-host): make managed updates converge
     new d8c164dab fix(runtime-host): make update recovery authoritative

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (a9817b496)
            \
             N -- N -- N   refs/heads/feat/managed-runtime-host-manual-update (d8c164dab)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 3 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.


Summary of changes:
 .github/ASF_NPM_RELEASE.md                         |     2 +-
 .github/workflows/ci.yml                           |    13 +-
 CHANGELOG.md                                       |     2 +
 DISCLAIMER-WIP                                     |     1 -
 LICENSE                                            |   255 +-
 NOTICE                                             |    11 +-
 README.md                                          |     4 +-
 README.zh-CN.md                                    |     4 +-
 apps/desktop/build/installer.nsh                   |     9 +-
 apps/desktop/e2e/sidebar-titlebar-actions.spec.ts  |    41 +
 apps/desktop/package.json                          |     2 +-
 .../app-shell-session-settings-actions.test.ts     |    51 +-
 .../__tests__/session-error-presentation.test.ts   |    38 +-
 .../__tests__/session-status-presentation.test.ts  |    14 +
 .../src/renderer/app-shell-command-actions.ts      |     6 +-
 .../renderer/app-shell-session-settings-actions.ts |    16 +-
 apps/desktop/src/renderer/app-shell.tsx            |    15 +-
 .../assets/provider-brands/github-copilot.svg      |     1 -
 .../src/renderer/locales/conversation-copy.ts      |     7 +-
 .../src/renderer/public/THIRD_PARTY_LICENSES.txt   |    11 -
 .../src/renderer/session-error-presentation.ts     |     2 +
 .../src/renderer/session-status-presentation.ts    |     3 +
 .../src/renderer/settings/provider-brand-marks.tsx |    13 +-
 apps/desktop/src/renderer/styles/shell-layout.css  |     9 +-
 biome.jsonc                                        |    15 +-
 docs/images/client-capability-bypass/after.png     |   Bin 0 -> 31651 bytes
 docs/images/client-capability-bypass/before.png    |   Bin 0 -> 50522 bytes
 package-lock.json                                  |     8 +-
 package.json                                       |     8 +-
 packages/cli/package.json                          |     2 +-
 .../__tests__/runtime-host-service-manager.test.ts |    92 +
 packages/cli/src/runtime-host-service-manager.ts   |    29 +
 .../__tests__/tool-result-record-schema.test.ts    |    16 +
 packages/core/src/events.ts                        |     2 +
 packages/core/src/model-metadata.generated.ts      |     3 +-
 packages/core/src/tool-result-record-schema.ts     |     5 +-
 .../deepseek-harness-profile/cordis.patch.yml      |    22 +-
 .../src/__tests__/handshake-compatibility.test.ts  |     4 +-
 .../runtime-host/src/__tests__/host-kernel.test.ts |     1 -
 .../runtime-host/src/__tests__/protocol.test.ts    |     4 +-
 packages/runtime-host/src/protocol/index.ts        |     4 +-
 packages/runtime-host/src/protocol/turn.ts         |     1 +
 .../runtime/src/__tests__/ai-sdk-backend.test.ts   |   189 +-
 packages/runtime/src/__tests__/ai-sdk-flow.test.ts |    23 +
 .../__tests__/pre-dispatch-refusal-ledger.test.ts  |    23 +
 .../provider-error-classification.test.ts          |    68 +
 packages/runtime/src/ai-sdk-backend.ts             |    42 +-
 packages/runtime/src/model-adapter.ts              |     4 +
 packages/runtime/src/model-protocol.ts             |     1 +
 .../runtime/src/provider-error-classification.ts   |    55 +-
 .../src/telemetry/model-pricing.generated.ts       |     3 +-
 packages/runtime/src/tool-runtime.ts               |    12 +-
 .../storage/src/__tests__/artifact-stores.test.ts  |    18 +-
 .../src/__tests__/artifact-writer-lock.test.ts     |    28 +-
 .../src/__tests__/daily-review-authority.test.ts   |    17 +-
 .../fixtures/control-directory-hygiene.ts          |    82 +
 .../storage/src/__tests__/goal-authority.test.ts   |    31 +-
 .../__tests__/managed-workspace-baseline.test.ts   |   110 +-
 .../src/__tests__/managed-workspace-owner.test.ts  |    91 +-
 .../src/__tests__/memory-bundle-store.test.ts      |     7 +-
 .../__tests__/project-catalog-authority.test.ts    |    14 +-
 .../src/__tests__/runtime-policy-stores.test.ts    |    61 +-
 .../src/__tests__/shell-run-authority.test.ts      |    17 +-
 .../__tests__/sqlite-core-execution-store.test.ts  |    14 +-
 .../sqlite-long-term-memory-store.test.ts          |    22 +-
 .../src/__tests__/sqlite-workflow-store.test.ts    |    14 +-
 .../src/__tests__/state-root-composition.test.ts   |    18 +-
 .../__tests__/storage-writer-composition.test.ts   |    26 +-
 .../src/__tests__/task-ledger-authority.test.ts    |    14 +-
 .../storage/src/__tests__/usage-stores.test.ts     |     7 +-
 packages/storage/src/runtime-policy/coordinator.ts |    13 +
 .../src/__tests__/conversation-copy.test.ts}       |    11 +-
 .../ui/src/__tests__/live-turn-projection.test.ts  |    49 +-
 packages/ui/src/__tests__/materialize.test.ts      |    25 +
 .../__tests__/tool-activity-presentation.test.ts   |    72 +
 .../ui/src/__tests__/transcript-projection.test.ts |    31 +
 packages/ui/src/chat-turn.tsx                      |    23 +-
 packages/ui/src/chat-view.tsx                      |    19 +-
 packages/ui/src/conversation-copy.ts               |    21 +-
 packages/ui/src/materialize.ts                     |    26 +-
 packages/ui/src/styles.css                         |     3 +-
 packages/ui/src/tool-activity.tsx                  |    78 +-
 packages/ui/src/tool-activity/copy.ts              |    30 +
 packages/ui/src/tool-activity/result-projection.ts |     6 +
 .../ui/src/tool-activity/tool-result-preview.tsx   |     5 +-
 packages/ui/src/transcript-projection.ts           |    13 +-
 packages/ui/stories/tool-activity.stories.tsx      |    35 +
 scripts/asf-license-headers.mjs                    |     1 +
 scripts/asf-license-headers.test.mjs               |     2 +
 scripts/asf-source-workflow-policy.test.mjs        |    19 +
 scripts/ci-test-plan.mjs                           |    24 +-
 scripts/ci-test-plan.test.mjs                      |    19 +-
 .../model-metadata/models-dev-api.snapshot.json    | 47435 +++++++++++++++++++
 scripts/product-release.test.mjs                   |    18 +
 scripts/source-legal-inventory.test.mjs            |    77 +
 scripts/sync-model-metadata.mjs                    |   441 +-
 scripts/sync-model-metadata.test.mjs               |   482 +
 scripts/verify-windows-sandbox-e2e.mjs             |   228 +-
 98 files changed, 50485 insertions(+), 478 deletions(-)
 create mode 100644 apps/desktop/e2e/sidebar-titlebar-actions.spec.ts
 copy packages/core/src/__tests__/incognito.test.ts => apps/desktop/src/main/__tests__/session-error-presentation.test.ts (50%)
 delete mode 100644 apps/desktop/src/renderer/assets/provider-brands/github-copilot.svg
 create mode 100644 docs/images/client-capability-bypass/after.png
 create mode 100644 docs/images/client-capability-bypass/before.png
 create mode 100644 packages/storage/src/__tests__/fixtures/control-directory-hygiene.ts
 copy packages/{cli/src/dev-cli.ts => ui/src/__tests__/conversation-copy.test.ts} (74%)
 create mode 100644 scripts/model-metadata/models-dev-api.snapshot.json
 create mode 100644 scripts/source-legal-inventory.test.mjs
 create mode 100644 scripts/sync-model-metadata.test.mjs


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


(maka) 03/03: fix(runtime-host): make update recovery authoritative

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/managed-runtime-host-manual-update
in repository https://gitbox.apache.org/repos/asf/maka.git

commit d8c164dabd13a26bf42b288a1d83a69c80f3e24c
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 23 20:57:04 2026 +0800

    fix(runtime-host): make update recovery authoritative
    
    Keep legacy operator ownership recoverable across updater interruption and repair an exact but unready service through the same replacement transaction. Require an exact systemd deployment and a fresh Desktop Host generation before reporting success.\n\nGenerated-by: Codex
---
 .../main/__tests__/runtime-host-management.test.ts |  16 +-
 apps/desktop/src/main/runtime-host-boot.ts         |  30 ++--
 apps/desktop/src/main/runtime-host-management.ts   |   5 +
 .../src/renderer/locales/settings-projects-copy.ts |   8 +-
 .../__tests__/runtime-host-service-manager.test.ts | 105 ++++++++++---
 .../cli/src/__tests__/runtime-host-setup.test.ts   |   1 +
 .../src/runtime-host-service-management-command.ts |   5 +-
 packages/cli/src/runtime-host-service-manager.ts   | 160 ++++++++++++-------
 packages/cli/src/runtime-host-systemd-service.ts   |  12 +-
 packages/cli/src/runtime-host-update-command.ts    | 170 ++++++++++++++++-----
 .../storage/src/__tests__/file-update-lock.test.ts |  17 ++-
 .../__tests__/fixtures/file-update-lock-holder.ts  |  20 ++-
 .../src/process-lifetime-file-update-lock.ts       |  68 ++++++++-
 13 files changed, 471 insertions(+), 146 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
index 1105f9e4d..ba5804640 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -334,6 +334,8 @@ test('publishes update progress and waits for the managed profile to reconnect',
   const progress: unknown[] = [];
   const connectionCompletions: unknown[] = [];
   let failConnection = false;
+  let bindingPresent = true;
+  let removeBindingAfterUpdate = false;
   const profile = {
     id: 'office',
     name: 'Office',
@@ -357,7 +359,8 @@ test('publishes update progress and waits for the managed profile to reconnect',
       removeHandler: (channel) => handlers.delete(channel),
     },
     profiles: {
-      resolveManagedService: async () => ({ profile, service, state: 'active' as const }),
+      resolveManagedService: async () =>
+        bindingPresent ? { profile, service, state: 'active' as const } : undefined,
       resolveManagedAccess: async () => undefined,
       rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
       markManagedServiceUninstalling: async (binding) => binding,
@@ -368,6 +371,7 @@ test('publishes update progress and waits for the managed profile to reconnect',
     runUpdate: async (input, onProgress) => {
       updates.push(input);
       onProgress('staging');
+      if (removeBindingAfterUpdate) bindingPresent = false;
       return {
         schemaVersion: 1,
         kind: 'result',
@@ -415,6 +419,16 @@ test('publishes update progress and waits for the managed profile to reconnect',
     [profile.id, profile.rootId, 'host-before-update', true],
   ]);
 
+  removeBindingAfterUpdate = true;
+  const changedProfile = await update({}, profile.id, false);
+  assert.equal(
+    (changedProfile as { kind: string; error?: { message: string } }).error?.message,
+    'The Runtime Host update completed, but Desktop could not reconnect: ' +
+      'Runtime Host profile changed while its service was updating',
+  );
+
+  bindingPresent = true;
+  removeBindingAfterUpdate = false;
   failConnection = true;
   const reconnectFailure = await update({}, profile.id, false);
   assert.deepEqual(reconnectFailure, {
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 74854ce5c..112155672 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -455,19 +455,11 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
     replacementExpected,
   ) => {
     if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable');
+    const manager = runtimeHostManager;
     const expectedPrevious = replacementExpected ? previousHostEpoch : undefined;
-    try {
-      await runtimeHostManager.waitUntilReady(
-        profileId,
-        expectedPrevious,
-        AbortSignal.timeout(MANAGED_UPDATE_RECONNECT_TIMEOUT_MS),
-      );
-      if (runtimeHostManager.current(profileId)?.hostId !== expectedHostId) {
-        throw new Error('Runtime Host profile changed while its service was updating');
-      }
-    } catch {
+    const reconnectExactTarget = async () => {
       await runtimeHostProfileService.reconnect(profileId, expectedHostId);
-      const current = runtimeHostManager.current(profileId);
+      const current = manager.current(profileId);
       if (
         current?.hostId !== expectedHostId ||
         !current.candidate ||
@@ -475,6 +467,22 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
       ) {
         throw new Error('Desktop reconnected to an unexpected Runtime Host generation');
       }
+    };
+    if (replacementExpected && previousHostEpoch === undefined) {
+      await reconnectExactTarget();
+      return;
+    }
+    try {
+      await manager.waitUntilReady(
+        profileId,
+        expectedPrevious,
+        AbortSignal.timeout(MANAGED_UPDATE_RECONNECT_TIMEOUT_MS),
+      );
+      if (manager.current(profileId)?.hostId !== expectedHostId) {
+        throw new Error('Runtime Host profile changed while its service was updating');
+      }
+    } catch {
+      await reconnectExactTarget();
     }
   },
   sendProgress: (progress) =>
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index dbce1f433..12d4751e6 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -31,6 +31,7 @@ import type {
   DesktopRuntimeHostManagementProgress,
 } from '../preload/bridge-contract.js';
 import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js';
+import { sameDesktopRuntimeHostManagedServiceBinding } from './runtime-host-managed-services.js';
 import type {
   DesktopRuntimeHostSshCleanupInput,
   DesktopRuntimeHostSshAccessInput,
@@ -249,6 +250,10 @@ export function createDesktopRuntimeHostManagement(input: {
       response.update.kind !== 'active_tasks'
     ) {
       try {
+        const current = await input.profiles.resolveManagedService(profileId);
+        if (!current || !sameDesktopRuntimeHostManagedServiceBinding(current, managed)) {
+          throw new Error('Runtime Host profile changed while its service was updating');
+        }
         await input.awaitUpdatedConnection(
           profileId,
           managed.profile.rootId,
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index d1a382a1b..64f43ddee 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -270,8 +270,8 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
         retiring: '正在安全停止当前 Runtime Host…',
         replacing: '正在启动并验证新版本…',
       },
-      updateBlockedTitle: 'Runtime Host 仍有活跃任务',
-      updateBlockedBody: '继续更新会中断当前执行,但会保留可恢复的任务状态和无法确认的外部效果。',
+      updateBlockedTitle: 'Runtime Host 可能仍在执行任务',
+      updateBlockedBody: '无法确认当前 Host 可以安全停止。继续更新会中断当前执行,但会保留可恢复的任务状态和无法确认的外部效果。',
       updateInterrupt: '中断任务并更新',
       updateComplete: (from: string, to: string) => `Runtime Host 已从 ${from} 更新到 ${to}`,
       updateRepaired: (version: string) => `Runtime Host ${version} 已恢复运行`,
@@ -445,8 +445,8 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
         retiring: 'Safely stopping the current Runtime Host…',
         replacing: 'Starting and verifying the new version…',
       },
-      updateBlockedTitle: 'Runtime Host still owns active work',
-      updateBlockedBody: 'Continuing will interrupt current execution while preserving recoverable task state and unresolved external effects.',
+      updateBlockedTitle: 'Runtime Host may still own active work',
+      updateBlockedBody: 'Desktop could not prove that the current Host can stop safely. Continuing will interrupt current execution while preserving recoverable task state and unresolved external effects.',
       updateInterrupt: 'Interrupt and update',
       updateComplete: (from: string, to: string) => `Runtime Host was updated from ${from} to ${to}`,
       updateRepaired: (version: string) => `Runtime Host ${version} is running again`,
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 528bd80e8..b0d9970a3 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -50,6 +50,7 @@ import { runManagedRuntimeHostUpdateCli } from '../runtime-host-update-command.j
 import {
   cleanupRuntimeHostManagedDeployment,
   manageRuntimeHostService,
+  replaceRuntimeHostManagedService,
   resolveRuntimeHostManagedServiceConfigPath,
   resolveRuntimeHostManagedServiceId,
   RuntimeHostServiceManagerError,
@@ -509,6 +510,7 @@ describe('managed Runtime Host service', () => {
       const systemd = createFakeSystemd(unitPath);
       return {
         unitPath,
+        systemd,
         backend: createSystemdUserRuntimeHostService(serviceId, {
           env,
           homeDir,
@@ -555,6 +557,13 @@ describe('managed Runtime Host service', () => {
         error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch',
     );
     await writeFile(release.unitPath, unit);
+    release.systemd.setDropInPaths(['/home/ada/.config/systemd/user/override.conf']);
+    await assert.rejects(
+      release.backend.verifyDeployment(releaseConfig),
+      (error: unknown) =>
+        error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch',
+    );
+    release.systemd.setDropInPaths([]);
     await manageRuntimeHostService(
       { ...input(developmentRoot), action: 'install' },
       development.backend,
@@ -1229,8 +1238,7 @@ describe('managed Runtime Host service', () => {
     await writeFile(previousCli, '#!/usr/bin/env node\n', 'utf8');
     await writeFile(targetCli, '#!/usr/bin/env node\n', 'utf8');
     let state: 'running' | 'stopped' = 'running';
-    let restoreOnFailure: boolean | undefined;
-    let installCalls = 0;
+    let replaceCalls = 0;
     const backend: RuntimeHostServiceBackend = {
       ...createReadyBackend(),
       status: async () => ({
@@ -1242,10 +1250,8 @@ describe('managed Runtime Host service', () => {
         pid: state === 'running' ? 42 : null,
         lastExitCode: 0,
       }),
-      install: async (_config, options) => {
-        installCalls += 1;
-        if (installCalls === 1) return { rollback: async () => undefined };
-        restoreOnFailure = options?.restoreOnFailure;
+      replace: async () => {
+        replaceCalls += 1;
         throw new Error('replacement failed after launch');
       },
       stop: async () => {
@@ -1269,14 +1275,11 @@ describe('managed Runtime Host service', () => {
       rootId: root.rootId,
     };
     await assert.rejects(
-      manageRuntimeHostService(
-        { ...common, action: 'update', cliPath: targetCli, expectedTarget },
-        backend,
-      ),
+      replaceRuntimeHostManagedService({ ...common, cliPath: targetCli, expectedTarget }, backend),
       (error: unknown) =>
         error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete',
     );
-    assert.equal(restoreOnFailure, false);
+    assert.equal(replaceCalls, 1);
     const config = JSON.parse(
       await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'),
     ) as RuntimeHostManagedServiceConfig;
@@ -1323,6 +1326,7 @@ describe('managed Runtime Host service', () => {
     let observedVersion = '1.0.0';
     let observedState: 'running' | 'stopped' = 'running';
     let readyChecks = 0;
+    let readyFailure = false;
     let operatorFailure: Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }> | undefined;
     let insideLifecycle = false;
     let output = '';
@@ -1348,6 +1352,10 @@ describe('managed Runtime Host service', () => {
         }
       },
       withDeploymentLock: async <T>(_root: string, operation: () => Promise<T>) => operation(),
+      withLegacyOperatorLeases: async <T>(
+        _root: string,
+        operation: (fds: readonly number[]) => Promise<T>,
+      ) => operation([]),
       prepareDeployment: async () => ({
         version: '2.0.0',
         root: deploymentRoot,
@@ -1365,8 +1373,28 @@ describe('managed Runtime Host service', () => {
         },
       }),
       runOperator: async (_operatorPath: string, args: readonly string[]) => {
-        order.push('retire');
-        assert.ok(args.includes('--allow-interrupt-active-tasks'));
+        const action = args[0];
+        assert.ok(action === 'retire' || action === 'stop');
+        order.push(action);
+        if (action === 'retire') assert.ok(args.includes('--allow-interrupt-active-tasks'));
+        if (action === 'stop') {
+          return {
+            schemaVersion: 1 as const,
+            kind: 'result' as const,
+            action: 'stop' as const,
+            service: {
+              platform: 'linux',
+              arch: 'x64',
+              osRelease: 'test',
+              state: 'stopped' as const,
+              pid: null,
+              lastExitCode: 3,
+              installedVersion: observedVersion,
+              stateRoot: expectedTarget.rootPath,
+              projectDirectoryRoots: [],
+            },
+          };
+        }
         if (operatorFailure) return operatorFailure;
         return {
           schemaVersion: 1 as const,
@@ -1388,17 +1416,18 @@ describe('managed Runtime Host service', () => {
       },
       verifyReady: async () => {
         readyChecks += 1;
+        if (readyFailure) throw new Error('Host is active but not ready');
       },
       manage: async (input: Parameters<typeof manageRuntimeHostService>[0]) => {
-        if (input.action === 'status') {
-          statusReads += 1;
-          if (statusReads > 1) assert.equal(insideLifecycle, true);
-          return service(observedVersion, statusReads === 1 ? observedState : 'stopped');
-        }
-        assert.equal(input.action, 'update');
+        assert.equal(input.action, 'status');
+        statusReads += 1;
+        if (statusReads > 1) assert.equal(insideLifecycle, true);
+        return service(observedVersion, statusReads === 1 ? observedState : 'stopped');
+      },
+      replace: async () => {
         assert.equal(insideLifecycle, true);
         order.push('replace');
-        return { ...service('2.0.0', 'running'), action: 'update' as const };
+        return service('2.0.0', 'running').service;
       },
       writeOutput: (value: string) => {
         output += value;
@@ -1443,15 +1472,39 @@ describe('managed Runtime Host service', () => {
     assert.equal(readyChecks, 1);
     assert.deepEqual(order, []);
 
+    order.length = 0;
+    statusReads = 0;
+    output = '';
+    readyFailure = true;
+    assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
+    assert.equal(readyChecks, 2);
+    assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']);
+    const activeRecovery = decodeRuntimeHostServiceManagementFrame(
+      output.trim().split('\n').at(-1) ?? '',
+    );
+    assert.equal(
+      activeRecovery?.kind === 'result' && activeRecovery.action === 'update'
+        ? activeRecovery.update.kind
+        : undefined,
+      'repaired',
+    );
+
+    order.length = 0;
     statusReads = 0;
-    observedVersion = '1.0.0';
     output = '';
     operatorFailure = {
       schemaVersion: 1,
       kind: 'error',
       action: 'retire',
-      error: { code: 'retirement_failed', message: 'The old Host rejected retirement' },
+      error: { code: 'retirement_failed', message: 'The active Host is not reachable' },
     };
+    assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
+    assert.deepEqual(order, ['retire', 'stop', 'activate', 'replace', 'cleanup']);
+
+    statusReads = 0;
+    observedVersion = '1.0.0';
+    output = '';
+    order.length = 0;
     assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 1);
     assert.deepEqual(order, ['retire', 'rollback']);
     const retirementFailure = decodeRuntimeHostServiceManagementFrame(
@@ -1589,6 +1642,7 @@ describe('managed Runtime Host service', () => {
 
 function createFakeSystemd(unitPath: string): {
   readonly failNext: (command: string) => void;
+  readonly setDropInPaths: (paths: readonly string[]) => void;
   readonly calls: readonly (readonly string[])[];
   readonly run: (args: readonly string[]) => Promise<{
     exitCode: number;
@@ -1600,12 +1654,16 @@ function createFakeSystemd(unitPath: string): {
   let enabled = false;
   let active = false;
   let failureCommand: string | undefined;
+  let dropInPaths: readonly string[] = [];
   const calls: string[][] = [];
   return {
     calls,
     failNext: (command) => {
       failureCommand = command;
     },
+    setDropInPaths: (paths) => {
+      dropInPaths = paths;
+    },
     run: async (args) => {
       calls.push([...args]);
       if (
@@ -1655,6 +1713,7 @@ function createFakeSystemd(unitPath: string): {
             `UnitFileState=${enabled ? 'enabled' : 'disabled'}`,
             `FragmentPath=${loaded ? unitPath : ''}`,
             'NeedDaemonReload=no',
+            `DropInPaths=${dropInPaths.join(' ')}`,
             `MainPID=${active ? '4242' : '0'}`,
             'ExecMainStatus=0',
             '',
@@ -1674,6 +1733,7 @@ function createUnusedBackend(): RuntimeHostServiceBackend {
   return {
     preflightInstall: unexpected,
     install: unexpected,
+    replace: unexpected,
     verifyDeployment: unexpected,
     status: unexpected,
     start: unexpected,
@@ -1704,6 +1764,7 @@ function createReadyBackend(): RuntimeHostServiceBackend {
   return {
     preflightInstall: async () => undefined,
     install: async () => ({ rollback: async () => undefined }),
+    replace: async () => undefined,
     verifyDeployment: async () => undefined,
     status,
     start: async () => undefined,
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 3013562ec..43d34f8b3 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -465,6 +465,7 @@ function unusedBackend(): RuntimeHostServiceBackend {
   return {
     preflightInstall: async () => undefined,
     install: async () => assert.fail('Backend is not expected'),
+    replace: async () => assert.fail('Backend is not expected'),
     verifyDeployment: async () => assert.fail('Backend is not expected'),
     status: async () => assert.fail('Backend is not expected'),
     start: async () => assert.fail('Backend is not expected'),
diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts
index 950cbcdc7..b76af516a 100644
--- a/packages/cli/src/runtime-host-service-management-command.ts
+++ b/packages/cli/src/runtime-host-service-management-command.ts
@@ -44,7 +44,7 @@ import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-serv
 
 export interface RuntimeHostServiceManagementCliOptions
   extends Omit<RuntimeHostManagedServiceInput, 'action'> {
-  readonly action: Exclude<RuntimeHostManagedServiceInput['action'], 'update'>;
+  readonly action: RuntimeHostManagedServiceInput['action'];
   readonly json: boolean;
   readonly framed?: boolean;
 }
@@ -169,9 +169,6 @@ function formatHumanResult(result: RuntimeHostManagedServiceResult): string {
 }
 
 function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServiceManagementFrame {
-  if (result.action === 'update') {
-    throw new TypeError('Managed Runtime Host updates use the update transaction');
-  }
   const service = runtimeHostServiceSummary(result);
   const common = {
     schemaVersion: 1,
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index fb3b77222..d6b73d196 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -31,7 +31,10 @@ import {
 } from '@maka/runtime-host/protocol';
 import { connectExistingRuntimeHost } from '@maka/runtime-host/client';
 import { RUNTIME_HOST_SERVICE_LOG_MAX_BYTES } from '@maka/runtime-host/operator';
-import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock';
+import {
+  withLegacyFileUpdateLockLease,
+  withProcessLifetimeFileUpdateLock,
+} from '@maka/storage/process-lifetime-file-update-lock';
 import {
   resolveExistingStorageRoot,
   tryAcquireInteractiveRootOwner,
@@ -90,10 +93,8 @@ export interface RuntimeHostServiceBackendStatus {
 
 export interface RuntimeHostServiceBackend {
   preflightInstall(): Promise<void>;
-  install(
-    config: RuntimeHostManagedServiceConfig,
-    options?: { readonly restoreOnFailure?: boolean },
-  ): Promise<RuntimeHostServiceDeployment>;
+  install(config: RuntimeHostManagedServiceConfig): Promise<RuntimeHostServiceDeployment>;
+  replace(config: RuntimeHostManagedServiceConfig): Promise<void>;
   verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise<void>;
   status(): Promise<RuntimeHostServiceBackendStatus>;
   start(): Promise<void>;
@@ -119,7 +120,6 @@ export type RuntimeHostManagedServiceAction =
   | 'stop'
   | 'restart'
   | 'retire'
-  | 'update'
   | 'logs'
   | 'uninstall';
 
@@ -176,6 +176,11 @@ export interface RuntimeHostManagedDeploymentCleanupInput {
   readonly expectedTarget: RuntimeHostManagedServiceTarget;
 }
 
+export interface RuntimeHostManagedServiceReplacementInput
+  extends Omit<RuntimeHostManagedServiceInput, 'action' | 'expectedTarget'> {
+  readonly expectedTarget: RuntimeHostManagedServiceTarget;
+}
+
 interface RuntimeHostServiceManagerDeps {
   readonly allocateLoopbackPort: () => Promise<number>;
   readonly waitForReady: (
@@ -270,6 +275,47 @@ export async function withRuntimeHostManagedServiceDeploymentLock<T>(
   );
 }
 
+export async function withRuntimeHostManagedServiceLegacyOperatorLeases<T>(
+  clientDataRoot: string,
+  operation: (inheritedFds: readonly number[]) => Promise<T>,
+  timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS,
+): Promise<T> {
+  const configPath = resolveRuntimeHostManagedServiceConfigPath(clientDataRoot);
+  await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
+  return withLegacyFileUpdateLockLease(
+    join(clientDataRoot, SERVICE_LIFECYCLE_LOCK_FILE),
+    (lifecycleFd) =>
+      withLegacyFileUpdateLockLease(
+        configPath,
+        (configFd) => operation([lifecycleFd, configFd]),
+        timeoutMs,
+      ),
+    timeoutMs,
+  );
+}
+
+export async function replaceRuntimeHostManagedService(
+  input: RuntimeHostManagedServiceReplacementInput,
+  backend: RuntimeHostServiceBackend,
+  overrides: Partial<RuntimeHostServiceManagerDeps> = {},
+): Promise<RuntimeHostManagedServiceStatus> {
+  const deps: RuntimeHostServiceManagerDeps = {
+    allocateLoopbackPort,
+    waitForReady: verifyRuntimeHostManagedServiceReady,
+    prepareRetirement: prepareRuntimeHostRetirement,
+    environment: process.env,
+    homeDir: homedir(),
+    ...overrides,
+  };
+  const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot);
+  await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
+  return withProcessLifetimeFileUpdateLock(
+    configPath,
+    () => replaceRuntimeHostManagedServiceLocked(input, backend, deps, configPath),
+    SERVICE_OPERATION_LOCK_TIMEOUT_MS,
+  );
+}
+
 export async function cleanupRuntimeHostManagedDeployment(
   input: RuntimeHostManagedDeploymentCleanupInput,
   backend: RuntimeHostServiceBackend,
@@ -444,55 +490,6 @@ async function manageRuntimeHostServiceLocked(
       await rootFence?.close().catch(() => undefined);
     }
   }
-  if (input.action === 'update') {
-    if (!input.expectedTarget) {
-      throw new RuntimeHostServiceManagerError(
-        'target_mismatch',
-        'Runtime Host update requires the expected managed service identity',
-      );
-    }
-    const service = await readServiceStatus(configPath, backend);
-    const root = await resolveExpectedServiceRoot(service.config, input);
-    if (!service.installed || !service.config || !root) {
-      throw new RuntimeHostServiceManagerError(
-        'not_installed',
-        'Runtime Host service is not installed',
-      );
-    }
-    if (
-      service.active ||
-      service.pid !== null ||
-      (service.state !== 'stopped' && service.state !== 'failed')
-    ) {
-      throw new RuntimeHostServiceManagerError(
-        'update_requires_retirement',
-        'Retire the managed Runtime Host service before replacing its package',
-      );
-    }
-    await backend.preflightInstall();
-    const config = await prepareServiceConfig(input, service.config, deps);
-    let rootFence: InteractiveRootOwner | undefined =
-      await acquireRuntimeHostRootRetirementFence(root);
-    try {
-      await writeRuntimeHostServiceFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
-      await releaseRuntimeHostRootRetirementFence(rootFence);
-      rootFence = undefined;
-    } finally {
-      await rootFence?.close().catch(() => undefined);
-    }
-    try {
-      await backend.install(config, { restoreOnFailure: false });
-      await deps.waitForReady(config, backend);
-    } catch (error) {
-      await backend.stop().catch(() => undefined);
-      throw new RuntimeHostServiceManagerError(
-        'update_incomplete',
-        'The replacement Runtime Host did not become ready; the previous deployment was retained but was not restarted because its storage compatibility is unknown',
-        { cause: error },
-      );
-    }
-    return result(input.action, await readServiceStatus(configPath, backend));
-  }
   const config = await readServiceConfig(configPath);
   if (!config) {
     throw new RuntimeHostServiceManagerError(
@@ -513,6 +510,57 @@ async function manageRuntimeHostServiceLocked(
   return result(input.action, await readServiceStatus(configPath, backend));
 }
 
+async function replaceRuntimeHostManagedServiceLocked(
+  input: RuntimeHostManagedServiceReplacementInput,
+  backend: RuntimeHostServiceBackend,
+  deps: RuntimeHostServiceManagerDeps,
+  configPath: string,
+): Promise<RuntimeHostManagedServiceStatus> {
+  const serviceId = resolveRuntimeHostManagedServiceId(input.clientDataRoot);
+  assertExpectedServiceIdentity(serviceId, input.expectedTarget);
+  const service = await readServiceStatus(configPath, backend);
+  const root = await resolveExpectedServiceRoot(service.config, input);
+  if (!service.installed || !service.config || !root) {
+    throw new RuntimeHostServiceManagerError(
+      'not_installed',
+      'Runtime Host service is not installed',
+    );
+  }
+  if (
+    service.active ||
+    service.pid !== null ||
+    (service.state !== 'stopped' && service.state !== 'failed')
+  ) {
+    throw new RuntimeHostServiceManagerError(
+      'update_requires_retirement',
+      'Retire the managed Runtime Host service before replacing its package',
+    );
+  }
+  await backend.preflightInstall();
+  const config = await prepareServiceConfig(input, service.config, deps);
+  let rootFence: InteractiveRootOwner | undefined =
+    await acquireRuntimeHostRootRetirementFence(root);
+  try {
+    await writeRuntimeHostServiceFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
+    await releaseRuntimeHostRootRetirementFence(rootFence);
+    rootFence = undefined;
+  } finally {
+    await rootFence?.close().catch(() => undefined);
+  }
+  try {
+    await backend.replace(config);
+    await deps.waitForReady(config, backend);
+  } catch (error) {
+    await backend.stop().catch(() => undefined);
+    throw new RuntimeHostServiceManagerError(
+      'update_incomplete',
+      'The replacement Runtime Host did not become ready; the previous deployment was retained but was not restarted because its storage compatibility is unknown',
+      { cause: error },
+    );
+  }
+  return readServiceStatus(configPath, backend);
+}
+
 function assertExpectedServiceIdentity(
   serviceId: string,
   expectedTarget: RuntimeHostManagedServiceTarget,
@@ -598,7 +646,7 @@ export async function removeRuntimeHostServiceFile(path: string, label: string):
 }
 
 async function prepareServiceConfig(
-  input: RuntimeHostManagedServiceInput,
+  input: Omit<RuntimeHostManagedServiceInput, 'action'>,
   previous: RuntimeHostManagedServiceConfig | null,
   deps: RuntimeHostServiceManagerDeps,
 ): Promise<RuntimeHostManagedServiceConfig> {
diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts
index 9c9519ff6..b99bc2b74 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -91,13 +91,12 @@ export function createSystemdUserRuntimeHostService(
       await assertUserSystemd(runSystemctl);
       await assertUserLinger(uid, runLoginctl);
     },
-    install: async (config, installOptions) => {
+    install: async (config) => {
       await validateLaunchFiles(config);
       const previous = await captureSystemdDeployment(context.unitPath, readStatus);
       try {
         await applySystemdDeployment(context, config);
       } catch (error) {
-        if (installOptions?.restoreOnFailure === false) throw error;
         await restoreFailedSystemdDeployment(previous, context, error);
       }
       let rolledBack = false;
@@ -109,6 +108,10 @@ export function createSystemdUserRuntimeHostService(
         },
       } satisfies RuntimeHostServiceDeployment;
     },
+    replace: async (config) => {
+      await validateLaunchFiles(config);
+      await applySystemdDeployment(context, config);
+    },
     verifyDeployment: async (config) => {
       await validateLaunchFiles(config);
       const [status, unit] = await Promise.all([
@@ -122,6 +125,7 @@ export function createSystemdUserRuntimeHostService(
         status.loadState !== 'loaded' ||
         status.fragmentPath !== context.unitPath ||
         status.needDaemonReload !== 'no' ||
+        Boolean(status.dropInPaths?.trim()) ||
         unit !== renderSystemdUnit(config)
       ) {
         throw new RuntimeHostServiceManagerError(
@@ -257,6 +261,7 @@ interface SystemdStatus {
   readonly unitFileState: string;
   readonly fragmentPath?: string;
   readonly needDaemonReload?: string;
+  readonly dropInPaths?: string;
   readonly mainPid?: string;
   readonly execMainStatus?: string;
 }
@@ -369,7 +374,7 @@ async function readSystemdStatus(context: SystemdUnitContext): Promise<SystemdSt
     result = await context.runSystemctl([
       'show',
       context.unitName,
-      '--property=LoadState,ActiveState,SubState,UnitFileState,FragmentPath,NeedDaemonReload,MainPID,ExecMainStatus',
+      '--property=LoadState,ActiveState,SubState,UnitFileState,FragmentPath,NeedDaemonReload,DropInPaths,MainPID,ExecMainStatus',
       '--no-pager',
     ]);
   } catch (error) {
@@ -390,6 +395,7 @@ async function readSystemdStatus(context: SystemdUnitContext): Promise<SystemdSt
     unitFileState: properties.get('UnitFileState') ?? 'disabled',
     fragmentPath: properties.get('FragmentPath'),
     needDaemonReload: properties.get('NeedDaemonReload'),
+    dropInPaths: properties.get('DropInPaths'),
     mainPid: properties.get('MainPID'),
     execMainStatus: properties.get('ExecMainStatus'),
   };
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
index 69005cc5d..7374344c0 100644
--- a/packages/cli/src/runtime-host-update-command.ts
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { execFile } from 'node:child_process';
+import { spawn } from 'node:child_process';
 import { join } from 'node:path';
 import { truncateUtf8 } from '@maka/core/diagnostic-log';
 import {
@@ -37,10 +37,12 @@ import {
 } from './runtime-host-managed-deployment.js';
 import {
   manageRuntimeHostService,
+  replaceRuntimeHostManagedService,
   resolveRuntimeHostManagedServiceId,
   RuntimeHostServiceManagerError,
   verifyRuntimeHostManagedServiceReady,
   withRuntimeHostManagedServiceDeploymentLock,
+  withRuntimeHostManagedServiceLegacyOperatorLeases,
   withRuntimeHostManagedServiceLifecycleLock,
   type RuntimeHostManagedServiceResult,
   type RuntimeHostManagedServiceTarget,
@@ -67,14 +69,17 @@ export interface RuntimeHostUpdateCliOptions {
 
 interface RuntimeHostUpdateCliDeps {
   readonly manage: typeof manageRuntimeHostService;
+  readonly replace: typeof replaceRuntimeHostManagedService;
   readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment;
   readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
   readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
+  readonly withLegacyOperatorLeases: typeof withRuntimeHostManagedServiceLegacyOperatorLeases;
   readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
   readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady;
   readonly runOperator: (
     operatorPath: string,
     args: readonly string[],
+    inheritedFds?: readonly number[],
   ) => Promise<RuntimeHostServiceManagementFrame>;
   readonly writeOutput: (value: string) => unknown;
   readonly writeError: (value: string) => unknown;
@@ -86,9 +91,11 @@ export async function runManagedRuntimeHostUpdateCli(
 ): Promise<number> {
   const deps: RuntimeHostUpdateCliDeps = {
     manage: manageRuntimeHostService,
+    replace: replaceRuntimeHostManagedService,
     prepareDeployment: prepareRuntimeHostManagedPackageDeployment,
     withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock,
     withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock,
+    withLegacyOperatorLeases: withRuntimeHostManagedServiceLegacyOperatorLeases,
     createBackend: createPlatformRuntimeHostServiceBackend,
     verifyReady: verifyRuntimeHostManagedServiceReady,
     runOperator: runManagedRuntimeHostOperator,
@@ -145,17 +152,22 @@ export async function runManagedRuntimeHostUpdateCli(
           );
         }
         emit(progress('checking', currentVersion, options.version));
+        let activeTargetNeedsRepair = false;
         if (currentVersion === options.version && status.service.active) {
-          await deps.verifyReady(serviceConfig, backend);
-          emit({
-            schemaVersion: 1,
-            kind: 'result',
-            action: 'update',
-            service: runtimeHostServiceSummary(status),
-            ...operatorCapabilities(),
-            update: { kind: 'already_current', version: options.version },
-          });
-          return 0;
+          try {
+            await deps.verifyReady(serviceConfig, backend);
+            emit({
+              schemaVersion: 1,
+              kind: 'result',
+              action: 'update',
+              service: runtimeHostServiceSummary(status),
+              ...operatorCapabilities(),
+              update: { kind: 'already_current', version: options.version },
+            });
+            return 0;
+          } catch {
+            activeTargetNeedsRepair = true;
+          }
         }
 
         emit(progress('staging', currentVersion, options.version));
@@ -174,14 +186,54 @@ export async function runManagedRuntimeHostUpdateCli(
           );
         }
 
-        if (currentVersion !== options.version) {
+        if (status.service.active) {
           emit(progress('retiring', currentVersion, options.version));
-          const retirement = await deps.runOperator(deployment.operatorPath, [
+          const currentOperatorPath = deployment.operatorPath;
+          const runCurrentOperator = (args: readonly string[]) =>
+            deps.withLegacyOperatorLeases(options.clientDataRoot, (inheritedFds) =>
+              deps.runOperator(currentOperatorPath, args, inheritedFds),
+            );
+          let retirement = await runCurrentOperator([
             'retire',
             '--framed',
             ...expectedTargetArgs(options.expectedTarget),
             ...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
           ]);
+          if (
+            activeTargetNeedsRepair &&
+            retirement.kind === 'error' &&
+            retirement.action === 'retire' &&
+            retirement.error.code === 'retirement_failed'
+          ) {
+            if (!options.allowInterruptActiveTasks) {
+              retirement = activeTasksRetirementFrame(status);
+            } else {
+              const forced = await runCurrentOperator([
+                'stop',
+                '--framed',
+                ...expectedTargetArgs(options.expectedTarget),
+              ]);
+              if (forced.kind === 'error') {
+                emit({ ...forced, action: 'update' });
+                return 1;
+              }
+              if (forced.kind !== 'result' || forced.action !== 'stop') {
+                throw new Error(
+                  'The current Runtime Host operator returned an invalid stop result',
+                );
+              }
+              retirement = {
+                schemaVersion: 1,
+                kind: 'result',
+                action: 'retire',
+                service: forced.service,
+                ...(forced.operatorCapabilities
+                  ? { operatorCapabilities: forced.operatorCapabilities }
+                  : {}),
+                retirement: { kind: 'stopped' },
+              };
+            }
+          }
           if (retirement.kind === 'error') {
             if (retirement.action !== 'retire') {
               throw new Error(
@@ -231,22 +283,22 @@ export async function runManagedRuntimeHostUpdateCli(
           cutoverStarted = true;
           await targetDeployment.activate();
           emit(progress('replacing', currentVersion, options.version));
-          const updated = await deps.manage(
+          const updatedService = await deps.replace(
             {
               ...common,
-              action: 'update',
               cliPath: targetDeployment.cliPath,
+              expectedTarget: options.expectedTarget,
             },
             backend,
           );
-          if (updated.service.installedVersion !== options.version || !updated.service.active) {
+          if (updatedService.installedVersion !== options.version || !updatedService.active) {
             throw new RuntimeHostServiceManagerError(
               'update_incomplete',
               'The replacement Runtime Host did not report the selected package version as ready',
             );
           }
           await targetDeployment.cleanup().catch(() => undefined);
-          return updated;
+          return { schemaVersion: 1, action: 'status', service: updatedService } as const;
         });
         emit({
           schemaVersion: 1,
@@ -344,6 +396,19 @@ function expectedTargetArgs(target: RuntimeHostManagedServiceTarget): string[] {
   ];
 }
 
+function activeTasksRetirementFrame(
+  status: RuntimeHostManagedServiceResult,
+): RuntimeHostServiceManagementFrame {
+  return {
+    schemaVersion: 1,
+    kind: 'result',
+    action: 'retire',
+    service: runtimeHostServiceSummary(status),
+    ...operatorCapabilities(),
+    retirement: { kind: 'active_tasks' },
+  };
+}
+
 function operatorCapabilities(): {
   readonly operatorCapabilities?: (typeof RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)[];
 } {
@@ -356,31 +421,60 @@ function operatorCapabilities(): {
 async function runManagedRuntimeHostOperator(
   operatorPath: string,
   args: readonly string[],
+  inheritedFds: readonly number[] = [],
 ): Promise<RuntimeHostServiceManagementFrame> {
   return new Promise((resolve, reject) => {
-    execFile(
-      operatorPath,
-      [...args],
-      {
-        encoding: 'utf8',
-        timeout: OPERATOR_TIMEOUT_MS,
-        maxBuffer: OPERATOR_OUTPUT_MAX_BYTES,
-      },
-      (error, stdout, stderr) => {
-        let frame: RuntimeHostServiceManagementFrame | undefined;
-        for (const line of stdout.split(/\r?\n/u)) {
-          frame = decodeRuntimeHostServiceManagementFrame(line) ?? frame;
-        }
-        if (frame) {
-          resolve(frame);
-          return;
-        }
-        const message = stderr.trim();
+    const child = spawn(operatorPath, [...args], {
+      // A detached legacy operator keeps the inherited advisory leases alive if
+      // this updater is interrupted, so an exact retry never steals active work.
+      detached: process.platform !== 'win32',
+      stdio: ['ignore', 'pipe', 'pipe', ...inheritedFds],
+      windowsHide: true,
+    });
+    if (!child.stdout || !child.stderr) {
+      child.kill('SIGKILL');
+      reject(new Error('The current Runtime Host operator did not expose its output streams'));
+      return;
+    }
+    const stdoutStream = child.stdout;
+    const stderrStream = child.stderr;
+    let stdout = '';
+    let stderr = '';
+    let failure: Error | undefined;
+    const append = (current: string, chunk: Buffer): string => {
+      const next = current + chunk.toString('utf8');
+      if (Buffer.byteLength(next) > OPERATOR_OUTPUT_MAX_BYTES) {
+        failure = new Error('The current Runtime Host operator returned too much output');
+        child.kill('SIGKILL');
+      }
+      return next;
+    };
+    stdoutStream.on('data', (chunk: Buffer) => {
+      stdout = append(stdout, chunk);
+    });
+    stderrStream.on('data', (chunk: Buffer) => {
+      stderr = append(stderr, chunk);
+    });
+    child.once('error', (error) => {
+      failure = error;
+    });
+    const timeout = setTimeout(() => {
+      failure = new Error('The current Runtime Host operator timed out');
+      child.kill('SIGKILL');
+    }, OPERATOR_TIMEOUT_MS);
+    child.once('close', () => {
+      clearTimeout(timeout);
+      let frame: RuntimeHostServiceManagementFrame | undefined;
+      for (const line of stdout.split(/\r?\n/u)) {
+        frame = decodeRuntimeHostServiceManagementFrame(line) ?? frame;
+      }
+      if (frame) resolve(frame);
+      else
         reject(
-          error ?? new Error(message || 'The current Runtime Host operator returned no result'),
+          failure ??
+            new Error(stderr.trim() || 'The current Runtime Host operator returned no result'),
         );
-      },
-    );
+    });
   });
 }
 
diff --git a/packages/storage/src/__tests__/file-update-lock.test.ts b/packages/storage/src/__tests__/file-update-lock.test.ts
index febf6f675..ade8983bb 100644
--- a/packages/storage/src/__tests__/file-update-lock.test.ts
+++ b/packages/storage/src/__tests__/file-update-lock.test.ts
@@ -22,15 +22,26 @@ import { mkdtemp, rm } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import assert from 'node:assert/strict';
-import { test } from 'node:test';
+import { test, type TestContext } from 'node:test';
 import { withProcessLifetimeFileUpdateLock } from '../process-lifetime-file-update-lock.js';
 
 test('releases a file update lock when its process is killed', async (t) => {
+  await assertKilledHolderCanBeRecovered(t, []);
+});
+
+test('recovers a supervised legacy directory lock when its process is killed', async (t) => {
+  await assertKilledHolderCanBeRecovered(t, ['legacy']);
+});
+
+async function assertKilledHolderCanBeRecovered(
+  t: TestContext,
+  args: readonly string[],
+): Promise<void> {
   const root = await mkdtemp(join(tmpdir(), 'maka-file-update-lock-'));
   const targetPath = join(root, 'state');
   const child = fork(
     new URL('./fixtures/file-update-lock-holder.js', import.meta.url),
-    [targetPath],
+    [targetPath, ...args],
     { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] },
   );
   t.after(async () => {
@@ -60,4 +71,4 @@ test('releases a file update lock when its process is killed', async (t) => {
     2_000,
   );
   assert.equal(entered, true);
-});
+}
diff --git a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts
index 4376b2742..218eaf0fb 100644
--- a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts
+++ b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts
@@ -17,12 +17,26 @@
  * under the License.
  */
 
-import { withProcessLifetimeFileUpdateLock } from '../../process-lifetime-file-update-lock.js';
+import { mkdir } from 'node:fs/promises';
+import {
+  withLegacyFileUpdateLockLease,
+  withProcessLifetimeFileUpdateLock,
+} from '../../process-lifetime-file-update-lock.js';
 
 const targetPath = process.argv[2];
 if (!targetPath) throw new Error('Missing file update lock target');
 
-await withProcessLifetimeFileUpdateLock(targetPath, async () => {
+const hold = async () => {
   process.send?.('locked');
   await new Promise<never>(() => setInterval(() => undefined, 1_000));
-});
+};
+
+if (process.argv[3] === 'legacy') {
+  await withLegacyFileUpdateLockLease(targetPath, async (inheritedFd) => {
+    if (inheritedFd <= 2) throw new Error('Legacy lock lease is not inheritable');
+    await mkdir(`${targetPath}.lock`);
+    await hold();
+  });
+} else {
+  await withProcessLifetimeFileUpdateLock(targetPath, hold);
+}
diff --git a/packages/storage/src/process-lifetime-file-update-lock.ts b/packages/storage/src/process-lifetime-file-update-lock.ts
index e94526f3b..3a928820f 100644
--- a/packages/storage/src/process-lifetime-file-update-lock.ts
+++ b/packages/storage/src/process-lifetime-file-update-lock.ts
@@ -20,13 +20,49 @@
 /// <reference path="./fs-native-extensions.d.ts" />
 
 import { constants as fsConstants } from 'node:fs';
-import { lstat, open, unlink, type FileHandle } from 'node:fs/promises';
+import { lstat, open, rmdir, unlink, type FileHandle } from 'node:fs/promises';
 import { tryLock, unlock } from 'fs-native-extensions';
 
 const LOCK_POLL_MS = 25;
 const LOCK_TIMEOUT_MS = 10_000;
 const lockGates = new Map<string, Promise<void>>();
 
+export async function withLegacyFileUpdateLockLease<T>(
+  targetPath: string,
+  operation: (inheritedFd: number) => Promise<T>,
+  timeoutMs: number = LOCK_TIMEOUT_MS,
+): Promise<T> {
+  const lockPath = `${targetPath}.lock`;
+  const leasePath = `${targetPath}.lease`;
+  const supervisionPath = `${targetPath}.supervised`;
+  const deadline = Date.now() + timeoutMs;
+  return runWithLockGate(leasePath, deadline, async () => {
+    const lease = await openStableLockFile(leasePath);
+    let leased = false;
+    let supervised = false;
+    let completed = false;
+    try {
+      while (!(leased = tryLock(lease.fd))) await waitForLockTurn(lockPath, deadline);
+      // The inherited advisory lease follows the legacy child process. A surviving
+      // supervision marker therefore proves that its directory lock is ownerless
+      // once a later process can acquire this lease.
+      await recoverSupervisedLegacyLock(lockPath, supervisionPath);
+      await createSupervisionMarker(supervisionPath);
+      supervised = true;
+      const result = await operation(lease.fd);
+      completed = true;
+      return result;
+    } finally {
+      try {
+        if (supervised && completed) await unlink(supervisionPath).catch(ignoreMissing);
+      } finally {
+        if (leased) releaseLock(lease);
+        await lease.close();
+      }
+    }
+  });
+}
+
 export async function withProcessLifetimeFileUpdateLock<T>(
   targetPath: string,
   operation: () => Promise<T>,
@@ -41,6 +77,7 @@ export async function withProcessLifetimeFileUpdateLock<T>(
     let markerCreated = false;
     try {
       while (!(leased = tryLock(lease.fd))) await waitForLockTurn(lockPath, deadline);
+      await recoverSupervisedLegacyLock(lockPath, `${targetPath}.supervised`);
       await acquireLegacyMarker(lockPath, deadline);
       markerCreated = true;
       return await operation();
@@ -55,6 +92,35 @@ export async function withProcessLifetimeFileUpdateLock<T>(
   });
 }
 
+async function createSupervisionMarker(path: string): Promise<void> {
+  const marker = await open(
+    path,
+    fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW,
+    0o600,
+  );
+  await marker.close();
+}
+
+async function recoverSupervisedLegacyLock(
+  lockPath: string,
+  supervisionPath: string,
+): Promise<void> {
+  const supervision = await lstat(supervisionPath).catch((error: unknown) => {
+    if (isNodeError(error, 'ENOENT')) return undefined;
+    throw error;
+  });
+  if (!supervision) return;
+  if (!supervision.isFile() || supervision.isSymbolicLink()) {
+    throw new Error(`File update supervision marker is not a regular file: ${supervisionPath}`);
+  }
+  const lock = await lstat(lockPath).catch((error: unknown) => {
+    if (isNodeError(error, 'ENOENT')) return undefined;
+    throw error;
+  });
+  if (lock?.isDirectory() && !lock.isSymbolicLink()) await rmdir(lockPath);
+  await unlink(supervisionPath);
+}
+
 async function runWithLockGate<T>(
   lockPath: string,
   deadline: number,


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


(maka) 01/03: feat(runtime-host): add safe managed Host updates

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/managed-runtime-host-manual-update
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 992c617482f6060d754e99cee18872f7b1512b6c
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 23 17:57:11 2026 +0800

    feat(runtime-host): add safe managed Host updates
    
    Add one exact-package replacement transaction that stages the target, retires the current Host under exact identity fences, and reports success only after replacement readiness. Keep unsafe rollback disabled once cutover may have started, and expose the same operation through Desktop SSH management.
    
    Generated-by: Codex
---
 .../main/__tests__/runtime-host-management.test.ts | 107 ++++++
 .../__tests__/runtime-host-ssh-terminal.test.ts    |  62 ++++
 apps/desktop/src/main/runtime-host-boot.ts         |   4 +
 apps/desktop/src/main/runtime-host-management.ts   |  94 ++++-
 apps/desktop/src/main/runtime-host-ssh-terminal.ts | 114 +++++-
 apps/desktop/src/preload/bridge-contract.d.ts      |  12 +
 apps/desktop/src/preload/preload.ts                |  19 +
 .../src/renderer/locales/settings-projects-copy.ts |  34 ++
 .../settings/runtime-host-management-dialog.tsx    |  91 +++++
 .../__tests__/runtime-host-service-manager.test.ts | 240 +++++++++++++
 .../cli/src/__tests__/runtime-host-setup.test.ts   |   3 +-
 packages/cli/src/cli-core.ts                       |  17 +
 packages/cli/src/runtime-host-cli.ts               |  27 +-
 .../cli/src/runtime-host-managed-deployment.ts     |   9 +-
 .../src/runtime-host-service-management-command.ts |  46 ++-
 packages/cli/src/runtime-host-service-manager.ts   |  71 +++-
 packages/cli/src/runtime-host-setup-command.ts     |  13 +-
 packages/cli/src/runtime-host-systemd-service.ts   |   3 +-
 packages/cli/src/runtime-host-update-command.ts    | 393 +++++++++++++++++++++
 packages/runtime-host/src/operator/index.ts        |   1 +
 .../src/operator/service-management-frame.ts       |  47 +++
 21 files changed, 1359 insertions(+), 48 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
index 26f03917e..e5612b875 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -28,6 +28,7 @@ import type {
   DesktopRuntimeHostSshAccessInput,
   DesktopRuntimeHostSshCleanupInput,
   DesktopRuntimeHostSshManagementInput,
+  DesktopRuntimeHostSshUpdateInput,
 } from '../runtime-host-ssh-terminal.js';
 
 test('identifies, rotates, and revokes managed credentials without exposing secrets', async () => {
@@ -64,6 +65,7 @@ test('identifies, rotates, and revokes managed credentials without exposing secr
   ];
 
   createDesktopRuntimeHostManagement({
+    ...unusedUpdateDependencies(),
     ipcMain: {
       handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
       removeHandler: (channel) => handlers.delete(channel),
@@ -185,6 +187,8 @@ test('manages only the service identity bound by Desktop onboarding', async () =
   const uninstallOrder: string[] = [];
   let operatorAccess = false;
   let cleared = 0;
+  let statusGate: Promise<void> | undefined;
+  let releaseStatus: (() => void) | undefined;
   const managedProfile = {
     id: 'office',
     name: 'Office',
@@ -203,6 +207,7 @@ test('manages only the service identity bound by Desktop onboarding', async () =
     operatorPath: '/home/operator/.local/share/maka/operator',
   };
   const management = createDesktopRuntimeHostManagement({
+    ...unusedUpdateDependencies(),
     ipcMain: {
       handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
       removeHandler: (channel) => handlers.delete(channel),
@@ -229,6 +234,7 @@ test('manages only the service identity bound by Desktop onboarding', async () =
     },
     runServiceManagement: async (input) => {
       managementInputs.push(input);
+      if (input.action === 'status') await statusGate;
       if (input.action === 'uninstall') {
         uninstallOrder.push('uninstall-service');
       }
@@ -243,6 +249,19 @@ test('manages only the service identity bound by Desktop onboarding', async () =
   const run = handlers.get('runtime-host-management:run');
   assert.ok(run);
 
+  statusGate = new Promise((resolve) => {
+    releaseStatus = resolve;
+  });
+  const firstStatus = run({}, 'office', 'status');
+  const secondStatus = run({}, 'office', 'status');
+  await Promise.resolve();
+  await Promise.resolve();
+  assert.equal(managementInputs.length, 1);
+  releaseStatus?.();
+  await Promise.all([firstStatus, secondStatus]);
+  statusGate = undefined;
+  managementInputs.length = 0;
+
   await assert.rejects(
     run({}, 'manual', 'uninstall') as Promise<unknown>,
     /not bound to a managed service/u,
@@ -309,6 +328,84 @@ test('manages only the service identity bound by Desktop onboarding', async () =
   assert.equal(handlers.size, 0);
 });
 
+test('updates the managed profile through its exact package and publishes progress', async () => {
+  const handlers = new Map<string, (...args: unknown[]) => unknown>();
+  const updates: DesktopRuntimeHostSshUpdateInput[] = [];
+  const progress: unknown[] = [];
+  const profile = {
+    id: 'office',
+    name: 'Office',
+    kind: 'remote' as const,
+    rootId: 'a'.repeat(64),
+    transport: {
+      kind: 'ssh' as const,
+      destination: 'operator@example.com',
+      remotePort: 7443,
+      websocketPath: '/runtime-host',
+    },
+  };
+  const service = {
+    id: 'b'.repeat(64),
+    rootPath: '/srv/maka',
+    operatorPath: '/home/operator/.local/share/maka/operator',
+  };
+  createDesktopRuntimeHostManagement({
+    ipcMain: {
+      handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
+      removeHandler: (channel) => handlers.delete(channel),
+    },
+    profiles: {
+      resolveManagedService: async () => ({ profile, service, state: 'active' as const }),
+      resolveManagedAccess: async () => undefined,
+      rotateManagedCredential: async () => assert.fail('credential rotation is not expected'),
+      markManagedServiceUninstalling: async (binding) => binding,
+      markManagedServiceCleanupPending: async (binding) => binding,
+      clearManagedServiceBinding: async () => undefined,
+    },
+    runServiceManagement: async () => assert.fail('ordinary management is not expected'),
+    runUpdate: async (input, onProgress) => {
+      updates.push(input);
+      onProgress('staging');
+      return {
+        schemaVersion: 1,
+        kind: 'result',
+        action: 'update',
+        service: {
+          platform: 'linux',
+          arch: 'x64',
+          osRelease: '6.8.0',
+          state: 'running',
+          pid: 43,
+          lastExitCode: 0,
+          installedVersion: '1.3.0',
+          projectDirectoryRoots: [],
+        },
+        operatorCapabilities: ['access-management-v1'],
+        update: { kind: 'updated', previousVersion: '1.2.3', targetVersion: '1.3.0' },
+      };
+    },
+    resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }),
+    sendProgress: (event) => progress.push(event),
+    runAccessManagement: async () => assert.fail('access management is not expected'),
+    cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
+  });
+
+  const update = handlers.get('runtime-host-management:update');
+  assert.ok(update);
+  const response = await update({}, profile.id, false);
+  assert.equal((response as { accessManagementAvailable: boolean }).accessManagementAvailable, true);
+  assert.deepEqual(updates, [{
+    destination: profile.transport.destination,
+    setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' },
+    expectedTarget: {
+      serviceId: service.id,
+      rootPath: service.rootPath,
+      rootId: profile.rootId,
+    },
+  }]);
+  assert.deepEqual(progress, [{ profileId: profile.id, phase: 'staging' }]);
+});
+
 test('resumes deployment cleanup without invoking the removed operator', async () => {
   const handlers = new Map<string, (...args: unknown[]) => unknown>();
   const profile = {
@@ -333,6 +430,7 @@ test('resumes deployment cleanup without invoking the removed operator', async (
   let state: 'active' | 'uninstalling' | 'cleanup_pending' = 'active';
   let clearAttempts = 0;
   createDesktopRuntimeHostManagement({
+    ...unusedUpdateDependencies(),
     ipcMain: {
       handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
       removeHandler: (channel) => handlers.delete(channel),
@@ -384,6 +482,7 @@ test('rechecks uninstall intent before retrying the remote service', async () =>
   const handlers = new Map<string, (...args: unknown[]) => unknown>();
   let marked = false;
   createDesktopRuntimeHostManagement({
+    ...unusedUpdateDependencies(),
     ipcMain: {
       handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown),
       removeHandler: (channel) => handlers.delete(channel),
@@ -464,6 +563,14 @@ function serviceResult(
     : { ...result, action };
 }
 
+function unusedUpdateDependencies() {
+  return {
+    runUpdate: async (): Promise<never> => assert.fail('update is not expected'),
+    resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const),
+    sendProgress: () => undefined,
+  };
+}
+
 function accessCredential(
   credentialId: string,
   principalId: string,
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 0ea4e53a3..6e5844c70 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
@@ -308,6 +308,68 @@ test('keeps a received management result when SSH teardown times out', async ()
   await harness.terminal.close();
 });
 
+test('runs an exact update package and reports progress before an active-work result', async () => {
+  const harness = createHarness('pending');
+  const phases: string[] = [];
+  const update = harness.terminal.runUpdate(
+    {
+      destination: 'operator@example.com',
+      setupPackage: { kind: 'npm', specifier: 'maka-agent@1.3.0' },
+      expectedTarget: {
+        serviceId: 'b'.repeat(64),
+        rootPath: '/srv/maka',
+        rootId: 'a'.repeat(64),
+      },
+    },
+    (phase) => phases.push(phase),
+  );
+  await waitFor(() => harness.pty.hasDataListener());
+  const remoteCommand = harness.launchArgs.at(-1)?.at(-1) ?? '';
+  assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u);
+  assert.match(remoteCommand, /runtime-host.*service.*update/u);
+  assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u);
+  harness.pty.emitData(
+    encodeRuntimeHostServiceManagementFrame({
+      schemaVersion: 1,
+      kind: 'progress',
+      action: 'update',
+      phase: 'retiring',
+      currentVersion: '1.2.3',
+      targetVersion: '1.3.0',
+    }),
+  );
+  harness.pty.emitData(
+    encodeRuntimeHostServiceManagementFrame({
+      schemaVersion: 1,
+      kind: 'result',
+      action: 'update',
+      service: {
+        platform: 'linux',
+        arch: 'x64',
+        osRelease: '6.8.0',
+        state: 'running',
+        pid: 42,
+        lastExitCode: 0,
+        installedVersion: '1.2.3',
+        projectDirectoryRoots: [],
+      },
+      update: {
+        kind: 'active_tasks',
+        currentVersion: '1.2.3',
+        targetVersion: '1.3.0',
+      },
+    }),
+  );
+  harness.pty.exit(1);
+
+  const result = await update;
+  assert.equal(result.kind, 'result');
+  assert.equal(result.kind === 'result' ? result.update.kind : undefined, 'active_tasks');
+  assert.deepEqual(phases, ['retiring']);
+  assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u);
+  await harness.terminal.close();
+});
+
 test('keeps a prepared access credential out of the SSH terminal projection', async () => {
   const harness = createHarness('pending');
   const credential = 'maka_rh_secret-replacement';
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 333dbe303..3fff6a048 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -443,6 +443,10 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
   ipcMain,
   profiles: runtimeHostProfileService,
   runServiceManagement: runtimeHostSshTerminal.runServiceManagement,
+  runUpdate: runtimeHostSshTerminal.runUpdate,
+  resolveUpdatePackage: runtimeHostSetupPackage,
+  sendProgress: (progress) =>
+    mainWindowController.send("runtime-host-management:progress", progress),
   runAccessManagement: runtimeHostSshTerminal.runAccessManagement,
   cleanupManagedDeployment: runtimeHostSshTerminal.cleanupManagedDeployment,
 });
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index 75a700d0a..a676ce38e 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -28,12 +28,16 @@ import type {
   DesktopRuntimeHostAccessSnapshot,
   DesktopRuntimeHostManagementAction,
   DesktopRuntimeHostManagementResponse,
+  DesktopRuntimeHostManagementProgress,
 } from '../preload/bridge-contract.js';
 import type { DesktopRuntimeHostProfileService } from './runtime-host-profile-service.js';
 import type {
   DesktopRuntimeHostSshCleanupInput,
   DesktopRuntimeHostSshAccessInput,
   DesktopRuntimeHostSshManagementInput,
+  DesktopRuntimeHostSshUpdateInput,
+  DesktopRuntimeHostSetupPackage,
+  RuntimeHostServiceUpdateTerminalFrame,
 } from './runtime-host-ssh-terminal.js';
 
 const MANAGEMENT_ACTIONS = new Set<DesktopRuntimeHostManagementAction>([
@@ -63,10 +67,16 @@ export function createDesktopRuntimeHostManagement(input: {
   >;
   readonly runServiceManagement: (
     input: DesktopRuntimeHostSshManagementInput,
-  ) => Promise<RuntimeHostServiceManagementFrame>;
+  ) => Promise<Exclude<RuntimeHostServiceManagementFrame, { kind: 'progress' }>>;
   readonly runAccessManagement: (
     input: DesktopRuntimeHostSshAccessInput,
   ) => Promise<RuntimeHostAccessManagementFrame>;
+  readonly runUpdate: (
+    input: DesktopRuntimeHostSshUpdateInput,
+    onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void,
+  ) => Promise<RuntimeHostServiceUpdateTerminalFrame>;
+  readonly resolveUpdatePackage: () => DesktopRuntimeHostSetupPackage;
+  readonly sendProgress: (progress: DesktopRuntimeHostManagementProgress) => void;
   readonly cleanupManagedDeployment: (
     input: DesktopRuntimeHostSshCleanupInput,
   ) => Promise<void>;
@@ -83,14 +93,11 @@ export function createDesktopRuntimeHostManagement(input: {
     return managed;
   };
 
-  const run = async (
-    profileId: unknown,
-    action: unknown,
+  const statusRequests = new Map<string, Promise<DesktopRuntimeHostManagementResponse>>();
+  const runManagedAction = async (
+    profileId: string,
+    managementAction: DesktopRuntimeHostManagementAction,
   ): Promise<DesktopRuntimeHostManagementResponse> => {
-    if (!MANAGEMENT_ACTIONS.has(action as DesktopRuntimeHostManagementAction)) {
-      throw new Error('Runtime Host service management action is invalid');
-    }
-    const managementAction = action as DesktopRuntimeHostManagementAction;
     const managed = await resolveManagedService(profileId);
     const { profile, service } = managed;
     if (profile.transport.kind !== 'ssh') {
@@ -152,6 +159,26 @@ export function createDesktopRuntimeHostManagement(input: {
     await input.profiles.clearManagedServiceBinding(pending);
     return { kind: 'uninstalled', retainedStateRoot: service.rootPath };
   };
+  const run = (
+    profileIdValue: unknown,
+    action: unknown,
+  ): Promise<DesktopRuntimeHostManagementResponse> => {
+    if (!MANAGEMENT_ACTIONS.has(action as DesktopRuntimeHostManagementAction)) {
+      throw new Error('Runtime Host service management action is invalid');
+    }
+    const profileId = requireProfileId(profileIdValue);
+    const managementAction = action as DesktopRuntimeHostManagementAction;
+    if (managementAction !== 'status') return runManagedAction(profileId, managementAction);
+    const existing = statusRequests.get(profileId);
+    if (existing) return existing;
+    const request = runManagedAction(profileId, managementAction);
+    statusRequests.set(profileId, request);
+    const forget = () => {
+      if (statusRequests.get(profileId) === request) statusRequests.delete(profileId);
+    };
+    void request.then(forget, forget);
+    return request;
+  };
 
   const resolveAccess = async (value: unknown) => {
     const profileId = requireProfileId(value);
@@ -181,6 +208,45 @@ export function createDesktopRuntimeHostManagement(input: {
     };
   };
 
+  const update = async (
+    profileIdValue: unknown,
+    allowInterruptActiveTasksValue: unknown,
+  ): Promise<DesktopRuntimeHostManagementResponse> => {
+    if (typeof allowInterruptActiveTasksValue !== 'boolean') {
+      throw new Error('Runtime Host update interruption authority is invalid');
+    }
+    const profileId = requireProfileId(profileIdValue);
+    const managed = await resolveManagedService(profileId);
+    if (managed.state !== 'active' || managed.profile.transport.kind !== 'ssh') {
+      throw new Error('This Runtime Host profile is not available for managed updates');
+    }
+    const response = await input.runUpdate(
+      {
+        destination: managed.profile.transport.destination,
+        ...(managed.profile.transport.sshPort === undefined
+          ? {}
+          : { sshPort: managed.profile.transport.sshPort }),
+        setupPackage: input.resolveUpdatePackage(),
+        expectedTarget: {
+          serviceId: managed.service.id,
+          rootPath: managed.service.rootPath,
+          rootId: managed.profile.rootId,
+        },
+        ...(allowInterruptActiveTasksValue ? { allowInterruptActiveTasks: true } : {}),
+      },
+      (phase) => input.sendProgress({ profileId, phase }),
+    );
+    return response.kind === 'result'
+      ? {
+          ...response,
+          accessManagementAvailable:
+            response.operatorCapabilities?.includes(
+              RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+            ) ?? false,
+        }
+      : response;
+  };
+
   const accessSnapshot = (
     credentials: Extract<
       RuntimeHostAccessManagementFrame,
@@ -298,18 +364,24 @@ export function createDesktopRuntimeHostManagement(input: {
 
   const channels = [
     'runtime-host-management:run',
+    'runtime-host-management:update',
     'runtime-host-management:list-credentials',
     'runtime-host-management:rotate-credential',
     'runtime-host-management:revoke-credential',
   ] as const;
   input.ipcMain.handle(channels[0], (_event, profileId: unknown, action: unknown) =>
     run(profileId, action));
-  input.ipcMain.handle(channels[1], (_event, profileId: unknown) =>
-    listCredentials(profileId));
+  input.ipcMain.handle(
+    channels[1],
+    (_event, profileId: unknown, allowInterruptActiveTasks: unknown) =>
+      update(profileId, allowInterruptActiveTasks),
+  );
   input.ipcMain.handle(channels[2], (_event, profileId: unknown) =>
+    listCredentials(profileId));
+  input.ipcMain.handle(channels[3], (_event, profileId: unknown) =>
     rotateCredential(profileId));
   input.ipcMain.handle(
-    channels[3],
+    channels[4],
     (_event, profileId: unknown, credentialId: unknown) =>
       revokeCredential(profileId, credentialId),
   );
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index cc415e761..12fe8ec27 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -44,6 +44,7 @@ import {
   type RuntimeHostAccessManagementFrame,
   type RuntimeHostServiceManagementAction,
   type RuntimeHostServiceManagementFrame,
+  type RuntimeHostServiceUpdatePhase,
   type RuntimeHostSetupFrame,
 } from '@maka/runtime-host/operator';
 import type {
@@ -83,7 +84,7 @@ export interface DesktopRuntimeHostSshManagementInput {
   readonly destination: string;
   readonly sshPort?: number;
   readonly operatorPath: string;
-  readonly action: RuntimeHostServiceManagementAction;
+  readonly action: Exclude<RuntimeHostServiceManagementAction, 'update'>;
   readonly expectedTarget: {
     readonly serviceId: string;
     readonly rootPath: string;
@@ -96,6 +97,15 @@ export interface DesktopRuntimeHostSshManagementInput {
   readonly signal?: AbortSignal;
 }
 
+export interface DesktopRuntimeHostSshUpdateInput {
+  readonly destination: string;
+  readonly sshPort?: number;
+  readonly setupPackage: DesktopRuntimeHostSetupPackage;
+  readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget'];
+  readonly allowInterruptActiveTasks?: boolean;
+  readonly signal?: AbortSignal;
+}
+
 export interface DesktopRuntimeHostSshCleanupInput {
   readonly destination: string;
   readonly sshPort?: number;
@@ -128,6 +138,12 @@ export type DesktopRuntimeHostSetupPackage =
   | { readonly kind: 'npm'; readonly specifier: string }
   | { readonly kind: 'development_archive'; readonly path: string };
 
+export type RuntimeHostServiceUpdateTerminalFrame =
+  | Extract<RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'update' }>
+  | (Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }> & {
+      readonly action: 'update';
+    });
+
 export function isExactRuntimeHostSetupPackageSpecifier(value: unknown): value is string {
   return typeof value === 'string' && /^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(value);
 }
@@ -151,7 +167,11 @@ export function createDesktopRuntimeHostSshTerminal(input: {
   ): Promise<RuntimeHostSetupCompleteFrame>;
   runServiceManagement(
     input: DesktopRuntimeHostSshManagementInput,
-  ): Promise<RuntimeHostServiceManagementFrame>;
+  ): Promise<Exclude<RuntimeHostServiceManagementFrame, { kind: 'progress' }>>;
+  runUpdate(
+    input: DesktopRuntimeHostSshUpdateInput,
+    onProgress: (phase: RuntimeHostServiceUpdatePhase) => void,
+  ): Promise<RuntimeHostServiceUpdateTerminalFrame>;
   runAccessManagement(
     input: DesktopRuntimeHostSshAccessInput,
   ): Promise<RuntimeHostAccessManagementFrame>;
@@ -364,7 +384,10 @@ export function createDesktopRuntimeHostSshTerminal(input: {
     readonly decode: (line: string) => Frame | undefined;
     readonly action: string;
     readonly frameAction: (frame: Frame) => string;
+    readonly isTerminalFrame?: (frame: Frame) => boolean;
+    readonly onProgress?: (frame: Frame) => void;
     readonly label: string;
+    readonly timeoutMs?: number;
   }): Promise<Frame> => {
     if (closed) throw new Error('Runtime Host SSH terminal is closed');
     options.signal?.throwIfAborted();
@@ -384,6 +407,10 @@ export function createDesktopRuntimeHostSshTerminal(input: {
           failure = new Error(`${options.label} returned ${action} for ${options.action}`);
           return;
         }
+        if (options.isTerminalFrame && !options.isTerminalFrame(next)) {
+          options.onProgress?.(next);
+          return;
+        }
         if (frame) {
           failure = new Error(`${options.label} returned multiple results`);
           return;
@@ -405,7 +432,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
     if (frame) completePresentation(terminal);
     const wait = await waitForTerminalProcess(process, {
       signal: options.signal,
-      timeoutMs: input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS,
+      timeoutMs: options.timeoutMs ?? input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS,
       stopGraceMs: input.processStopGraceMs,
       onAbort: () => dismissPresentation(terminal),
     });
@@ -510,8 +537,8 @@ export function createDesktopRuntimeHostSshTerminal(input: {
         cancellation.close();
       }
     },
-    runServiceManagement: (managementInput) =>
-      runFramedManagement({
+    runServiceManagement: async (managementInput) => {
+      const frame = await runFramedManagement({
         ...managementInput,
         remoteCommand: runtimeHostServiceManagementRemoteCommand(managementInput),
         prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
@@ -520,7 +547,52 @@ export function createDesktopRuntimeHostSshTerminal(input: {
         action: managementInput.action,
         frameAction: (frame) => frame.action,
         label: 'Remote Runtime Host service management',
-      }),
+      });
+      if (frame.kind === 'progress') {
+        throw new Error('Remote Runtime Host service management returned update progress');
+      }
+      return frame;
+    },
+    runUpdate: async (updateInput, onProgress) => {
+      if (closed) throw new Error('Runtime Host SSH terminal is closed');
+      updateInput.signal?.throwIfAborted();
+      const destination = normalizeRuntimeHostSshDestination(updateInput.destination);
+      const sshPort = updateInput.sshPort === undefined
+        ? undefined
+        : requireSetupPort(updateInput.sshPort);
+      const setupPackage = await prepareSetupPackage(
+        updateInput.setupPackage,
+        destination,
+        sshPort,
+        updateInput.expectedTarget.serviceId,
+        startTerminalProcess,
+        updateInput.signal,
+        input.processStopGraceMs,
+        dismissPresentation,
+      );
+      const frame = await runFramedManagement({
+        ...updateInput,
+        destination,
+        ...(sshPort === undefined ? {} : { sshPort }),
+        remoteCommand: runtimeHostUpdateRemoteCommand(setupPackage, updateInput),
+        prefix: RUNTIME_HOST_SERVICE_MANAGEMENT_FRAME_PREFIX,
+        pendingMaxBytes: MANAGEMENT_FRAME_PENDING_MAX,
+        decode: decodeRuntimeHostServiceManagementFrame,
+        action: 'update',
+        frameAction: (candidate) => candidate.action,
+        isTerminalFrame: (candidate) => candidate.kind !== 'progress',
+        onProgress: (candidate) => {
+          if (candidate.kind === 'progress') onProgress(candidate.phase);
+        },
+        label: 'Remote Runtime Host update',
+        timeoutMs: SETUP_TIMEOUT_MS,
+      });
+      if (frame.kind === 'result' && frame.action === 'update') return frame;
+      if (frame.kind === 'error' && frame.action === 'update') {
+        return { ...frame, action: 'update' };
+      }
+      throw new Error('Remote Runtime Host update returned an invalid result');
+    },
     runAccessManagement: (accessInput) =>
       runFramedManagement({
         ...accessInput,
@@ -861,6 +933,27 @@ function runtimeHostServiceManagementRemoteCommand(
   return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(invocation)}`;
 }
 
+function runtimeHostUpdateRemoteCommand(
+  setupPackage: PreparedSetupPackage,
+  input: DesktopRuntimeHostSshUpdateInput,
+): string {
+  return runtimeHostPackageRemoteCommand(
+    setupPackage,
+    [
+      'runtime-host',
+      'service',
+      'update',
+      '--framed',
+      ...managedServiceTargetArgs(input.expectedTarget),
+      ...(input.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
+    ],
+    {
+      [RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV]:
+        RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+    },
+  );
+}
+
 function runtimeHostAccessManagementRemoteCommand(
   input: DesktopRuntimeHostSshAccessInput,
 ): string {
@@ -919,11 +1012,16 @@ function managedServiceTargetArgs(input: {
 function runtimeHostPackageRemoteCommand(
   setupPackage: PreparedSetupPackage,
   args: readonly string[],
+  environment: Readonly<Record<string, string>> = {},
 ): string {
   const commandArgs = ['maka', ...args].map(quotePosix).join(' ');
+  const environmentPrefix = Object.entries(environment)
+    .map(([name, value]) => `${name}=${quotePosix(value)}`)
+    .join(' ');
+  const invocationPrefix = environmentPrefix ? `${environmentPrefix} ` : '';
   const commandInvocation = setupPackage.removeAfterSetup
-    ? `npx --yes --package ${quotePosix(setupPackage.specifier)} ${commandArgs}`
-    : `npx --yes --prefix "$maka_command_prefix" --package ${quotePosix(setupPackage.specifier)} ${commandArgs}`;
+    ? `${invocationPrefix}npx --yes --package ${quotePosix(setupPackage.specifier)} ${commandArgs}`
+    : `${invocationPrefix}npx --yes --prefix "$maka_command_prefix" --package ${quotePosix(setupPackage.specifier)} ${commandArgs}`;
   const command = setupPackage.removeAfterSetup
     ? `cd "$HOME" || exit 1; maka_command_exit=0; ${commandInvocation} || maka_command_exit=$?; rm -f -- ${quotePosix(setupPackage.removeAfterSetup)}; exit "$maka_command_exit"`
     : `maka_command_prefix=$(mktemp -d) || exit 1; trap 'rm -rf -- "$maka_command_prefix"' EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; cd "$maka_command_prefix" || exit 1; ${commandInvocation}`;
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 1230edc60..04516630a 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -435,6 +435,11 @@ export type DesktopRuntimeHostManagementResponse =
       readonly retainedStateRoot: string;
     };
 
+export interface DesktopRuntimeHostManagementProgress {
+  readonly profileId: string;
+  readonly phase: import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase;
+}
+
 export interface DesktopRuntimeHostAccessCredential {
   readonly credentialId: string;
   readonly principalKind: 'remote_owner' | 'capability_provider';
@@ -562,6 +567,13 @@ export interface MakaBridge {
       profileId: string,
       action: DesktopRuntimeHostManagementAction,
     ): Promise<DesktopRuntimeHostManagementResponse>;
+    update(
+      profileId: string,
+      allowInterruptActiveTasks: boolean,
+    ): Promise<DesktopRuntimeHostManagementResponse>;
+    subscribeProgress(
+      handler: (progress: DesktopRuntimeHostManagementProgress) => void,
+    ): () => void;
     listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
     rotateCredential(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot>;
     revokeCredential(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index a9ea2df06..0c712f3a4 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -45,6 +45,7 @@ import type {
   DesktopRuntimeHostOnboardingSnapshot,
   DesktopRuntimeHostManagementAction,
   DesktopRuntimeHostManagementResponse,
+  DesktopRuntimeHostManagementProgress,
   DesktopRuntimeHostAccessSnapshot,
   DesktopNewTaskCatalog,
   DesktopNewTaskHost,
@@ -1199,6 +1200,24 @@ const makaBridge = {
     ): Promise<DesktopRuntimeHostManagementResponse> {
       return ipcRenderer.invoke('runtime-host-management:run', profileId, action);
     },
+    update(
+      profileId: string,
+      allowInterruptActiveTasks: boolean,
+    ): Promise<DesktopRuntimeHostManagementResponse> {
+      return ipcRenderer.invoke(
+        'runtime-host-management:update',
+        profileId,
+        allowInterruptActiveTasks,
+      );
+    },
+    subscribeProgress(handler: (progress: DesktopRuntimeHostManagementProgress) => void) {
+      const listener = (
+        _event: Electron.IpcRendererEvent,
+        progress: DesktopRuntimeHostManagementProgress,
+      ) => handler(progress);
+      ipcRenderer.on('runtime-host-management:progress', listener);
+      return () => ipcRenderer.off('runtime-host-management:progress', listener);
+    },
     listCredentials(profileId: string): Promise<DesktopRuntimeHostAccessSnapshot> {
       return ipcRenderer.invoke('runtime-host-management:list-credentials', profileId);
     },
diff --git a/apps/desktop/src/renderer/locales/settings-projects-copy.ts b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
index 68af99b20..d1a382a1b 100644
--- a/apps/desktop/src/renderer/locales/settings-projects-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-projects-copy.ts
@@ -91,6 +91,14 @@ export type SettingsProjectsCopy = {
     startService: string;
     restartService: string;
     repairService: string;
+    updateService: string;
+    updatePhase: Record<import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase, string>;
+    updateBlockedTitle: string;
+    updateBlockedBody: string;
+    updateInterrupt: string;
+    updateComplete(from: string, to: string): string;
+    updateRepaired(version: string): string;
+    updateAlreadyCurrent(version: string): string;
     showLogs: string;
     noLogs: string;
     uninstallService: string;
@@ -255,6 +263,19 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       startService: '启动',
       restartService: '重启',
       repairService: '修复',
+      updateService: '安装 Desktop 版本',
+      updatePhase: {
+        checking: '正在检查版本…',
+        staging: '正在准备新版本…',
+        retiring: '正在安全停止当前 Runtime Host…',
+        replacing: '正在启动并验证新版本…',
+      },
+      updateBlockedTitle: 'Runtime Host 仍有活跃任务',
+      updateBlockedBody: '继续更新会中断当前执行,但会保留可恢复的任务状态和无法确认的外部效果。',
+      updateInterrupt: '中断任务并更新',
+      updateComplete: (from: string, to: string) => `Runtime Host 已从 ${from} 更新到 ${to}`,
+      updateRepaired: (version: string) => `Runtime Host ${version} 已恢复运行`,
+      updateAlreadyCurrent: (version: string) => `Runtime Host 已是 ${version}`,
       showLogs: '查看日志',
       noLogs: '没有服务日志',
       uninstallService: '卸载服务',
@@ -417,6 +438,19 @@ const SETTINGS_PROJECTS_COPY_BY_LOCALE = {
       startService: 'Start',
       restartService: 'Restart',
       repairService: 'Repair',
+      updateService: 'Install Desktop version',
+      updatePhase: {
+        checking: 'Checking versions…',
+        staging: 'Staging the new version…',
+        retiring: 'Safely stopping the current Runtime Host…',
+        replacing: 'Starting and verifying the new version…',
+      },
+      updateBlockedTitle: 'Runtime Host still owns active work',
+      updateBlockedBody: 'Continuing will interrupt current execution while preserving recoverable task state and unresolved external effects.',
+      updateInterrupt: 'Interrupt and update',
+      updateComplete: (from: string, to: string) => `Runtime Host was updated from ${from} to ${to}`,
+      updateRepaired: (version: string) => `Runtime Host ${version} is running again`,
+      updateAlreadyCurrent: (version: string) => `Runtime Host is already on ${version}`,
       showLogs: 'View logs',
       noLogs: 'No service logs were found',
       uninstallService: 'Uninstall service',
diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
index ee78cc87b..be1780e6b 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx
@@ -27,6 +27,7 @@ import type { RemoteRuntimeHostProfile } from '@maka/runtime-host/client';
 import type {
   DesktopRuntimeHostManagementAction,
   DesktopRuntimeHostManagementResult,
+  DesktopRuntimeHostManagementProgress,
   DesktopRuntimeHostAccessCredential,
   DesktopRuntimeHostAccessSnapshot,
 } from '../../preload/bridge-contract.js';
@@ -35,6 +36,7 @@ import { settingsActionErrorMessage } from './settings-error-copy.js';
 
 type RuntimeHostManagementConfirmation =
   | { readonly kind: 'uninstall' }
+  | { readonly kind: 'update' }
   | { readonly kind: 'rotate' }
   | {
       readonly kind: 'revoke';
@@ -54,6 +56,7 @@ export function RuntimeHostManagementDialog(props: {
   const [uninstalledRoot, setUninstalledRoot] = useState<string>();
   const [access, setAccess] = useState<DesktopRuntimeHostAccessSnapshot>();
   const [confirmation, setConfirmation] = useState<RuntimeHostManagementConfirmation>();
+  const [updatePhase, setUpdatePhase] = useState<DesktopRuntimeHostManagementProgress['phase']>();
   const logsRef = useRef<HTMLPreElement>(null);
 
   const profile = props.profile;
@@ -65,6 +68,7 @@ export function RuntimeHostManagementDialog(props: {
     setUninstalledRoot(undefined);
     setAccess(undefined);
     setConfirmation(undefined);
+    setUpdatePhase(undefined);
     setLoading(true);
     void window.maka.runtimeHostManagement.run(profile.id, 'status').then(
       (response) => {
@@ -84,6 +88,10 @@ export function RuntimeHostManagementDialog(props: {
     };
   }, [locale, profile]);
 
+  useEffect(() => window.maka.runtimeHostManagement.subscribeProgress((progress) => {
+    if (progress.profileId === profile?.id) setUpdatePhase(progress.phase);
+  }), [profile?.id]);
+
   useLayoutEffect(() => {
     if (result?.action !== 'logs') return;
     const logs = logsRef.current;
@@ -131,6 +139,40 @@ export function RuntimeHostManagementDialog(props: {
     }
   }
 
+  async function update(allowInterruptActiveTasks: boolean): Promise<void> {
+    if (!profile) return;
+    setLoading(true);
+    setError(undefined);
+    setUpdatePhase('checking');
+    try {
+      const response = await window.maka.runtimeHostManagement.update(
+        profile.id,
+        allowInterruptActiveTasks,
+      );
+      if (response.kind === 'error') {
+        setError(response.error.message);
+        toast.error(copy.managementActionFailed, response.error.message);
+        return;
+      }
+      if (response.kind === 'uninstalled') {
+        throw new Error('Runtime Host update returned an uninstall result');
+      }
+      setResult(response);
+      setConfirmation(
+        response.action === 'update' && response.update.kind === 'active_tasks'
+          ? { kind: 'update' }
+          : undefined,
+      );
+    } catch (failure) {
+      const message = settingsActionErrorMessage(failure, locale);
+      setError(message);
+      toast.error(copy.managementActionFailed, message);
+    } finally {
+      setLoading(false);
+      setUpdatePhase(undefined);
+    }
+  }
+
   async function rotateCredential(): Promise<void> {
     if (!profile) return;
     setLoading(true);
@@ -200,6 +242,7 @@ export function RuntimeHostManagementDialog(props: {
               {loading ? (
                 <div className="settingsRuntimeHostSetupProgress" role="status">
                   <Spinner size="sm" />
+                  {updatePhase ? <Text type="supporting">{copy.updatePhase[updatePhase]}</Text> : null}
                 </div>
               ) : null}
               {error ? <Banner status="error" title={error} /> : null}
@@ -210,6 +253,13 @@ export function RuntimeHostManagementDialog(props: {
                   description={copy.uninstallConfirmBody}
                 />
               ) : null}
+              {confirmation?.kind === 'update' ? (
+                <Banner
+                  status="warning"
+                  title={copy.updateBlockedTitle}
+                  description={copy.updateBlockedBody}
+                />
+              ) : null}
               {confirmation?.kind === 'rotate' ? (
                 <Banner
                   status="warning"
@@ -223,6 +273,24 @@ export function RuntimeHostManagementDialog(props: {
                   title={copy.uninstallRetained(uninstalledRoot)}
                 />
               ) : null}
+              {result?.action === 'update' && result.update.kind === 'updated' ? (
+                <Banner
+                  status="success"
+                  title={copy.updateComplete(
+                    result.update.previousVersion,
+                    result.update.targetVersion,
+                  )}
+                />
+              ) : null}
+              {result?.action === 'update' && result.update.kind === 'already_current' ? (
+                <Banner
+                  status="info"
+                  title={copy.updateAlreadyCurrent(result.update.version)}
+                />
+              ) : null}
+              {result?.action === 'update' && result.update.kind === 'repaired' ? (
+                <Banner status="success" title={copy.updateRepaired(result.update.version)} />
+              ) : null}
               {!access && service ? (
                 <>
                   <dl className="settingsRuntimeHostManagementFacts">
@@ -366,6 +434,21 @@ export function RuntimeHostManagementDialog(props: {
                     onClick={() => void revokeCredential()}
                   />
                 </>
+              ) : confirmation?.kind === 'update' ? (
+                <>
+                  <Button
+                    variant="secondary"
+                    label={copy.cancel}
+                    isDisabled={loading}
+                    onClick={() => setConfirmation(undefined)}
+                  />
+                  <Button
+                    variant="destructive"
+                    label={copy.updateInterrupt}
+                    isDisabled={loading}
+                    onClick={() => void update(true)}
+                  />
+                </>
               ) : confirmation?.kind === 'uninstall' ? (
                 <>
                   <Button
@@ -447,6 +530,14 @@ export function RuntimeHostManagementDialog(props: {
                         isDisabled={loading}
                         onClick={() => void run('status')}
                       />
+                      {serviceInstalled ? (
+                        <Button
+                          variant="secondary"
+                          label={copy.updateService}
+                          isDisabled={loading}
+                          onClick={() => void update(false)}
+                        />
+                      ) : null}
                       {serviceInstalled ? (
                         <Button
                           variant="secondary"
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 1689d2f16..47f5a9826 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -45,6 +45,7 @@ import {
   resolveRuntimeHostManagedDeploymentRoot,
 } from '../runtime-host-managed-deployment.js';
 import { runManagedRuntimeHostServiceCli } from '../runtime-host-service-management-command.js';
+import { runManagedRuntimeHostUpdateCli } from '../runtime-host-update-command.js';
 import {
   cleanupRuntimeHostManagedDeployment,
   manageRuntimeHostService,
@@ -156,6 +157,31 @@ describe('managed Runtime Host service', () => {
       },
     );
     assert.equal(parseRuntimeHostCommand(['service', 'retire', '--framed']).kind, 'error');
+    assert.deepEqual(
+      parseRuntimeHostCommand([
+        'service',
+        'update',
+        '--framed',
+        '--allow-interrupt-active-tasks',
+        '--expected-service-id',
+        'b'.repeat(64),
+        '--expected-root-path',
+        '/srv/maka',
+        '--expected-root-id',
+        'a'.repeat(64),
+      ]),
+      {
+        kind: 'runtime-host-service-update',
+        json: false,
+        framed: true,
+        allowInterruptActiveTasks: true,
+        expectedTarget: {
+          serviceId: 'b'.repeat(64),
+          rootPath: '/srv/maka',
+          rootId: 'a'.repeat(64),
+        },
+      },
+    );
     assert.deepEqual(
       parseRuntimeHostCommand([
         'service',
@@ -577,6 +603,7 @@ describe('managed Runtime Host service', () => {
             'Persistent user services are disabled',
           );
         },
+        withDeploymentLock: async (_root, operation) => operation(),
         withLifecycleLock: async (_root, operation) => operation(),
         createBackend: createUnusedBackend,
         writeOutput: (value) => {
@@ -1176,6 +1203,219 @@ describe('managed Runtime Host service', () => {
     assert.match(await readFile(unitPath, 'utf8'), /--websocket-port" "41001"/u);
   });
 
+  it('keeps the selected package configured when replacement readiness is unknown', async (t) => {
+    const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-update-failure-'));
+    t.after(() => rm(base, { recursive: true, force: true }));
+    const clientDataRoot = join(base, 'config');
+    const root = await resolveStorageRoot({ path: join(base, 'state'), kind: 'interactive' });
+    const previousCli = join(base, 'previous', 'dist', 'cli.js');
+    const targetCli = join(base, 'target', 'dist', 'cli.js');
+    await mkdir(dirname(previousCli), { recursive: true });
+    await mkdir(dirname(targetCli), { recursive: true });
+    await writeFile(previousCli, '#!/usr/bin/env node\n', 'utf8');
+    await writeFile(targetCli, '#!/usr/bin/env node\n', 'utf8');
+    let state: 'running' | 'stopped' = 'running';
+    let restoreOnFailure: boolean | undefined;
+    let installCalls = 0;
+    const backend: RuntimeHostServiceBackend = {
+      ...createReadyBackend(),
+      status: async () => ({
+        manager: 'systemd_user',
+        installed: true,
+        enabled: true,
+        active: state === 'running',
+        state,
+        pid: state === 'running' ? 42 : null,
+        lastExitCode: 0,
+      }),
+      install: async (_config, options) => {
+        installCalls += 1;
+        if (installCalls === 1) return { rollback: async () => undefined };
+        restoreOnFailure = options?.restoreOnFailure;
+        throw new Error('replacement failed after launch');
+      },
+      stop: async () => {
+        state = 'stopped';
+      },
+    };
+    const common = {
+      clientDataRoot,
+      defaultRootPath: root.canonicalPath,
+      nodePath: process.execPath,
+    } as const;
+    await manageRuntimeHostService(
+      { ...common, action: 'install', cliPath: previousCli },
+      backend,
+      { waitForReady: async () => undefined },
+    );
+    state = 'stopped';
+    const expectedTarget = {
+      serviceId: resolveRuntimeHostManagedServiceId(clientDataRoot),
+      rootPath: root.canonicalPath,
+      rootId: root.rootId,
+    };
+    await assert.rejects(
+      manageRuntimeHostService(
+        { ...common, action: 'update', cliPath: targetCli, expectedTarget },
+        backend,
+      ),
+      (error: unknown) =>
+        error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete',
+    );
+    assert.equal(restoreOnFailure, false);
+    const config = JSON.parse(
+      await readFile(resolveRuntimeHostManagedServiceConfigPath(clientDataRoot), 'utf8'),
+    ) as RuntimeHostManagedServiceConfig;
+    assert.equal(config.launch.cliPath, await realpath(targetCli));
+  });
+
+  it('updates through the current operator before activating the target service', async () => {
+    const clientDataRoot = '/home/ada/.config/maka';
+    const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot);
+    const deploymentRoot = '/home/ada/.local/share/Maka/runtime-host-services/service';
+    const expectedTarget = {
+      serviceId,
+      rootPath: '/home/ada/.local/share/Maka/workspaces/default',
+      rootId: 'a'.repeat(64),
+    };
+    const order: string[] = [];
+    const service = (version: string, state: 'running' | 'stopped') =>
+      ({
+        schemaVersion: 1,
+        action: 'status',
+        service: {
+          manager: 'systemd_user',
+          installed: true,
+          enabled: true,
+          active: state === 'running',
+          state,
+          pid: state === 'running' ? 42 : null,
+          lastExitCode: 0,
+          installedVersion: version,
+          config: {
+            schemaVersion: 1,
+            managedDeploymentRoot: deploymentRoot,
+            rootPath: expectedTarget.rootPath,
+            projectDirectoryRoots: [],
+            websocket: { host: '127.0.0.1', port: 7400, path: '/runtime-host' },
+            launch: {
+              nodePath: process.execPath,
+              cliPath: join(deploymentRoot, 'versions', version, 'dist', 'cli.js'),
+            },
+          },
+        },
+      }) satisfies RuntimeHostManagedServiceResult;
+    let statusReads = 0;
+    let observedVersion = '1.0.0';
+    let observedState: 'running' | 'stopped' = 'running';
+    let insideLifecycle = false;
+    let output = '';
+    const options = {
+      json: false,
+      framed: true,
+      clientDataRoot,
+      defaultRootPath: expectedTarget.rootPath,
+      sourcePackageRoot: '/target-package',
+      version: '2.0.0',
+      expectedTarget,
+      allowInterruptActiveTasks: true,
+    } as const;
+    const overrides = {
+      createBackend: createUnusedBackend,
+      withLifecycleLock: async <T>(_root: string, operation: () => Promise<T>) => {
+        assert.equal(insideLifecycle, false);
+        insideLifecycle = true;
+        try {
+          return await operation();
+        } finally {
+          insideLifecycle = false;
+        }
+      },
+      withDeploymentLock: async <T>(_root: string, operation: () => Promise<T>) => operation(),
+      prepareDeployment: async () => ({
+        version: '2.0.0',
+        root: deploymentRoot,
+        cliPath: join(deploymentRoot, 'versions', '2.0.0', 'dist', 'cli.js'),
+        operatorPath: join(deploymentRoot, 'operator'),
+        activate: async () => {
+          assert.equal(insideLifecycle, true);
+          order.push('activate');
+        },
+        cleanup: async () => {
+          order.push('cleanup');
+        },
+        rollback: async () => {
+          order.push('rollback');
+        },
+      }),
+      runOperator: async (_operatorPath: string, args: readonly string[]) => {
+        order.push('retire');
+        assert.ok(args.includes('--allow-interrupt-active-tasks'));
+        return {
+          schemaVersion: 1 as const,
+          kind: 'result' as const,
+          action: 'retire' as const,
+          service: {
+            platform: 'linux',
+            arch: 'x64',
+            osRelease: 'test',
+            state: 'stopped' as const,
+            pid: null,
+            lastExitCode: 3,
+            installedVersion: '1.0.0',
+            stateRoot: expectedTarget.rootPath,
+            projectDirectoryRoots: [],
+          },
+          retirement: { kind: 'retired' as const, hostEpoch: 'host-1', pid: 42 },
+        };
+      },
+      manage: async (input: Parameters<typeof manageRuntimeHostService>[0]) => {
+        if (input.action === 'status') {
+          statusReads += 1;
+          if (statusReads > 1) assert.equal(insideLifecycle, true);
+          return service(observedVersion, statusReads === 1 ? observedState : 'stopped');
+        }
+        assert.equal(input.action, 'update');
+        assert.equal(insideLifecycle, true);
+        order.push('replace');
+        return { ...service('2.0.0', 'running'), action: 'update' as const };
+      },
+      writeOutput: (value: string) => {
+        output += value;
+      },
+    };
+    const exitCode = await runManagedRuntimeHostUpdateCli(options, overrides);
+    assert.equal(exitCode, 0);
+    assert.deepEqual(order, ['retire', 'activate', 'replace', 'cleanup']);
+    const frames = output
+      .trim()
+      .split('\n')
+      .map((line) => decodeRuntimeHostServiceManagementFrame(line));
+    const result = frames.at(-1);
+    assert.equal(result?.kind, 'result');
+    assert.equal(
+      result?.kind === 'result' && result.action === 'update' ? result.update.kind : undefined,
+      'updated',
+    );
+
+    order.length = 0;
+    statusReads = 0;
+    observedVersion = '2.0.0';
+    observedState = 'stopped';
+    output = '';
+    assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
+    assert.deepEqual(order, ['activate', 'replace', 'cleanup']);
+    const recovery = decodeRuntimeHostServiceManagementFrame(
+      output.trim().split('\n').at(-1) ?? '',
+    );
+    assert.equal(
+      recovery?.kind === 'result' && recovery.action === 'update'
+        ? recovery.update.kind
+        : undefined,
+      'repaired',
+    );
+  });
+
   it('rejects invalid Project roots and temporary npx launch paths before deployment', async (t) => {
     const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-input-'));
     t.after(() => rm(base, { recursive: true, force: true }));
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 66891be8a..01bab4e9a 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -357,7 +357,8 @@ test('managed operator binds its Client Data Root and routes deployment cleanup'
       platform: 'linux',
     },
   );
-  await deployment.commit();
+  await deployment.activate();
+  await deployment.cleanup();
 
   const invocationPath = join(base, 'operator-argv.json');
   await writeFile(
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index 5638eeec2..8eceb9d81 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -122,6 +122,7 @@ function helpText(cliCommand: string): string {
     `  ${cliCommand} runtime-host service install [options]`,
     `  ${cliCommand} runtime-host service status|start|stop|restart|logs|uninstall [--json]`,
     `  ${cliCommand} runtime-host service retire --expected-service-id <id> --expected-root-path <path> --expected-root-id <id> [--allow-interrupt-active-tasks]`,
+    `  ${cliCommand} runtime-host service update --expected-service-id <id> --expected-root-path <path> --expected-root-id <id> [--allow-interrupt-active-tasks]`,
     `  ${cliCommand} runtime-host access issue --principal <id> --grant <operation>`,
     `  ${cliCommand} runtime-host access issue --principal <id> --preset <desktop-client|terminal-client>`,
     `  ${cliCommand} runtime-host access list`,
@@ -275,6 +276,22 @@ export async function runMakaCli(
         ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
       });
     }
+    case 'runtime-host-service-update': {
+      const { runManagedRuntimeHostUpdateCli } = await import('./runtime-host-update-command.js');
+      const serviceDataRoots = command.clientDataRoot
+        ? deriveMakaDataRoots(command.clientDataRoot)
+        : dataRoots;
+      return runManagedRuntimeHostUpdateCli({
+        json: command.json,
+        framed: command.framed ?? false,
+        clientDataRoot: serviceDataRoots.clientDataRoot,
+        defaultRootPath: serviceDataRoots.workspaceRoot,
+        sourcePackageRoot: fileURLToPath(new URL('..', import.meta.url)),
+        version,
+        expectedTarget: command.expectedTarget,
+        ...(command.allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
+      });
+    }
     case 'runtime-host-managed-deployment-cleanup': {
       const { runManagedRuntimeHostDeploymentCleanupCli } = await import(
         './runtime-host-service-management-command.js'
diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts
index c151016a7..bd4d20f5b 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -69,6 +69,14 @@ export type RuntimeHostCliCommand =
       retainManagedDeployment?: true;
       allowInterruptActiveTasks?: true;
     }
+  | {
+      kind: 'runtime-host-service-update';
+      json: boolean;
+      framed?: true;
+      clientDataRoot?: string;
+      expectedTarget: RuntimeHostManagedServiceTarget;
+      allowInterruptActiveTasks?: true;
+    }
   | {
       kind: 'runtime-host-managed-deployment-cleanup';
       clientDataRoot?: string;
@@ -227,13 +235,14 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
     action !== 'stop' &&
     action !== 'restart' &&
     action !== 'retire' &&
+    action !== 'update' &&
     action !== 'logs' &&
     action !== 'uninstall'
   ) {
     return error(
       action
         ? `Unexpected runtime-host service command: ${action}`
-        : 'runtime-host service requires install, status, start, stop, restart, retire, logs, or uninstall',
+        : 'runtime-host service requires install, status, start, stop, restart, retire, update, logs, or uninstall',
     );
   }
 
@@ -248,7 +257,7 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
             retainManagedDeployment = true;
           },
         }
-      : action === 'retire'
+      : action === 'retire' || action === 'update'
         ? {
             '--allow-interrupt-active-tasks': () => {
               if (allowInterruptActiveTasks) {
@@ -271,8 +280,18 @@ function parseServiceManagementCommand(argv: string[]): RuntimeHostCliCommand {
     flagOptions,
   });
   if ('kind' in options) return options;
-  if (action === 'retire' && !options.expectedTarget) {
-    return error('runtime-host service retire requires an expected target');
+  if ((action === 'retire' || action === 'update') && !options.expectedTarget) {
+    return error(`runtime-host service ${action} requires an expected target`);
+  }
+  if (action === 'update') {
+    return {
+      kind: 'runtime-host-service-update',
+      json: options.json,
+      ...(options.framed ? { framed: true } : {}),
+      ...(clientDataRoot ? { clientDataRoot } : {}),
+      expectedTarget: options.expectedTarget!,
+      ...(allowInterruptActiveTasks ? { allowInterruptActiveTasks: true } : {}),
+    };
   }
   return {
     kind: 'runtime-host-service-manage',
diff --git a/packages/cli/src/runtime-host-managed-deployment.ts b/packages/cli/src/runtime-host-managed-deployment.ts
index dacdf8e87..f5dccc1ad 100644
--- a/packages/cli/src/runtime-host-managed-deployment.ts
+++ b/packages/cli/src/runtime-host-managed-deployment.ts
@@ -51,7 +51,8 @@ export interface RuntimeHostManagedPackageDeployment {
   readonly root: string;
   readonly cliPath: string;
   readonly operatorPath: string;
-  commit(): Promise<void>;
+  activate(): Promise<void>;
+  cleanup(): Promise<void>;
   rollback(): Promise<void>;
 }
 
@@ -267,10 +268,8 @@ function deployment(
     root,
     cliPath,
     operatorPath,
-    commit: async () => {
-      await writeOperatorLauncher(operatorPath, process.execPath, cliPath, clientDataRoot);
-      await pruneInactiveDevelopmentPackages(dirname(packageRoot), version);
-    },
+    activate: () => writeOperatorLauncher(operatorPath, process.execPath, cliPath, clientDataRoot),
+    cleanup: () => pruneInactiveDevelopmentPackages(dirname(packageRoot), version),
     rollback: () =>
       created ? rm(packageRoot, { recursive: true, force: true }) : Promise.resolve(),
   };
diff --git a/packages/cli/src/runtime-host-service-management-command.ts b/packages/cli/src/runtime-host-service-management-command.ts
index 73ca83971..950cbcdc7 100644
--- a/packages/cli/src/runtime-host-service-management-command.ts
+++ b/packages/cli/src/runtime-host-service-management-command.ts
@@ -33,6 +33,7 @@ import {
   manageRuntimeHostService,
   resolveRuntimeHostManagedServiceId,
   RuntimeHostServiceManagerError,
+  withRuntimeHostManagedServiceDeploymentLock,
   withRuntimeHostManagedServiceLifecycleLock,
   type RuntimeHostManagedServiceInput,
   type RuntimeHostManagedServiceResult,
@@ -41,13 +42,16 @@ import {
 } from './runtime-host-service-manager.js';
 import { createSystemdUserRuntimeHostService } from './runtime-host-systemd-service.js';
 
-export interface RuntimeHostServiceManagementCliOptions extends RuntimeHostManagedServiceInput {
+export interface RuntimeHostServiceManagementCliOptions
+  extends Omit<RuntimeHostManagedServiceInput, 'action'> {
+  readonly action: Exclude<RuntimeHostManagedServiceInput['action'], 'update'>;
   readonly json: boolean;
   readonly framed?: boolean;
 }
 
 export interface RuntimeHostServiceManagementCliDeps {
   readonly manage: typeof manageRuntimeHostService;
+  readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
   readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
   readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
   readonly writeOutput: (value: string) => unknown;
@@ -60,6 +64,7 @@ export async function runManagedRuntimeHostServiceCli(
 ): Promise<number> {
   const deps: RuntimeHostServiceManagementCliDeps = {
     manage: manageRuntimeHostService,
+    withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock,
     withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock,
     createBackend: createPlatformRuntimeHostServiceBackend,
     writeOutput: (value) => process.stdout.write(value),
@@ -73,7 +78,11 @@ export async function runManagedRuntimeHostServiceCli(
     const result =
       options.action === 'status' || options.action === 'logs'
         ? await manage()
-        : await deps.withLifecycleLock(options.clientDataRoot, manage);
+        : options.action === 'retire'
+          ? await deps.withLifecycleLock(options.clientDataRoot, manage)
+          : await deps.withDeploymentLock(options.clientDataRoot, () =>
+              deps.withLifecycleLock(options.clientDataRoot, manage),
+            );
     const blocked = result.action === 'retire' && result.retirement.kind === 'active_tasks';
     if (options.framed) {
       deps.writeOutput(encodeRuntimeHostServiceManagementFrame(successFrame(result)));
@@ -160,18 +169,10 @@ function formatHumanResult(result: RuntimeHostManagedServiceResult): string {
 }
 
 function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServiceManagementFrame {
-  const config = result.service.config;
-  const service: RuntimeHostServiceSummary = {
-    platform: process.platform,
-    arch: process.arch,
-    osRelease: release(),
-    state: result.service.state,
-    pid: result.service.pid,
-    lastExitCode: result.service.lastExitCode,
-    installedVersion: result.service.installedVersion,
-    ...(config ? { stateRoot: config.rootPath } : {}),
-    projectDirectoryRoots: [...(config?.projectDirectoryRoots ?? [])],
-  };
+  if (result.action === 'update') {
+    throw new TypeError('Managed Runtime Host updates use the update transaction');
+  }
+  const service = runtimeHostServiceSummary(result);
   const common = {
     schemaVersion: 1,
     kind: 'result',
@@ -192,6 +193,23 @@ function successFrame(result: RuntimeHostManagedServiceResult): RuntimeHostServi
     : { ...common, action: result.action };
 }
 
+export function runtimeHostServiceSummary(
+  result: RuntimeHostManagedServiceResult,
+): RuntimeHostServiceSummary {
+  const config = result.service.config;
+  return {
+    platform: process.platform,
+    arch: process.arch,
+    osRelease: release(),
+    state: result.service.state,
+    pid: result.service.pid,
+    lastExitCode: result.service.lastExitCode,
+    installedVersion: result.service.installedVersion,
+    ...(config ? { stateRoot: config.rootPath } : {}),
+    projectDirectoryRoots: [...(config?.projectDirectoryRoots ?? [])],
+  };
+}
+
 export function createPlatformRuntimeHostServiceBackend(
   serviceId: string,
 ): RuntimeHostServiceBackend {
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index c0754a383..5b3c9dec1 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -46,6 +46,7 @@ import {
 
 const SERVICE_CONFIG_FILE = 'runtime-host-service.json';
 const SERVICE_LIFECYCLE_LOCK_FILE = 'runtime-host-setup';
+const SERVICE_DEPLOYMENT_LOCK_FILE = 'runtime-host-deployment';
 const DEFAULT_WEBSOCKET_PATH = '/runtime-host';
 const SERVICE_OPERATION_LOCK_TIMEOUT_MS = 60_000;
 const SERVICE_READY_TIMEOUT_MS = 45_000;
@@ -89,7 +90,10 @@ export interface RuntimeHostServiceBackendStatus {
 
 export interface RuntimeHostServiceBackend {
   preflightInstall(): Promise<void>;
-  install(config: RuntimeHostManagedServiceConfig): Promise<RuntimeHostServiceDeployment>;
+  install(
+    config: RuntimeHostManagedServiceConfig,
+    options?: { readonly restoreOnFailure?: boolean },
+  ): Promise<RuntimeHostServiceDeployment>;
   status(): Promise<RuntimeHostServiceBackendStatus>;
   start(): Promise<void>;
   stop(): Promise<void>;
@@ -114,6 +118,7 @@ export type RuntimeHostManagedServiceAction =
   | 'stop'
   | 'restart'
   | 'retire'
+  | 'update'
   | 'logs'
   | 'uninstall';
 
@@ -199,6 +204,8 @@ export class RuntimeHostServiceManagerError extends Error {
       | 'invalid_launch'
       | 'target_mismatch'
       | 'retirement_failed'
+      | 'update_requires_retirement'
+      | 'update_incomplete'
       | 'service_manager_operation_failed'
       | 'uninstall_incomplete',
     message: string,
@@ -249,6 +256,19 @@ export async function withRuntimeHostManagedServiceLifecycleLock<T>(
   );
 }
 
+export async function withRuntimeHostManagedServiceDeploymentLock<T>(
+  clientDataRoot: string,
+  operation: () => Promise<T>,
+  timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS,
+): Promise<T> {
+  await mkdir(clientDataRoot, { recursive: true, mode: 0o700 });
+  return withFileUpdateLock(
+    join(clientDataRoot, SERVICE_DEPLOYMENT_LOCK_FILE),
+    operation,
+    timeoutMs,
+  );
+}
+
 export async function cleanupRuntimeHostManagedDeployment(
   input: RuntimeHostManagedDeploymentCleanupInput,
   backend: RuntimeHostServiceBackend,
@@ -423,6 +443,55 @@ async function manageRuntimeHostServiceLocked(
       await rootFence?.close().catch(() => undefined);
     }
   }
+  if (input.action === 'update') {
+    if (!input.expectedTarget) {
+      throw new RuntimeHostServiceManagerError(
+        'target_mismatch',
+        'Runtime Host update requires the expected managed service identity',
+      );
+    }
+    const service = await readServiceStatus(configPath, backend);
+    const root = await resolveExpectedServiceRoot(service.config, input);
+    if (!service.installed || !service.config || !root) {
+      throw new RuntimeHostServiceManagerError(
+        'not_installed',
+        'Runtime Host service is not installed',
+      );
+    }
+    if (
+      service.active ||
+      service.pid !== null ||
+      (service.state !== 'stopped' && service.state !== 'failed')
+    ) {
+      throw new RuntimeHostServiceManagerError(
+        'update_requires_retirement',
+        'Retire the managed Runtime Host service before replacing its package',
+      );
+    }
+    await backend.preflightInstall();
+    const config = await prepareServiceConfig(input, service.config, deps);
+    let rootFence: InteractiveRootOwner | undefined =
+      await acquireRuntimeHostRootRetirementFence(root);
+    try {
+      await writeRuntimeHostServiceFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
+      await releaseRuntimeHostRootRetirementFence(rootFence);
+      rootFence = undefined;
+    } finally {
+      await rootFence?.close().catch(() => undefined);
+    }
+    try {
+      await backend.install(config, { restoreOnFailure: false });
+      await deps.waitForReady(config, backend);
+    } catch (error) {
+      await backend.stop().catch(() => undefined);
+      throw new RuntimeHostServiceManagerError(
+        'update_incomplete',
+        'The replacement Runtime Host did not become ready; the previous deployment was retained but was not restarted because its storage compatibility is unknown',
+        { cause: error },
+      );
+    }
+    return result(input.action, await readServiceStatus(configPath, backend));
+  }
   const config = await readServiceConfig(configPath);
   if (!config) {
     throw new RuntimeHostServiceManagerError(
diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts
index e411be953..b5875bf42 100644
--- a/packages/cli/src/runtime-host-setup-command.ts
+++ b/packages/cli/src/runtime-host-setup-command.ts
@@ -47,6 +47,7 @@ import {
   manageRuntimeHostService,
   resolveRuntimeHostManagedServiceId,
   RuntimeHostServiceManagerError,
+  withRuntimeHostManagedServiceDeploymentLock,
   withRuntimeHostManagedServiceLifecycleLock,
   type RuntimeHostManagedServiceResult,
   type RuntimeHostManagedServiceTarget,
@@ -112,9 +113,14 @@ export async function runRuntimeHostSetupCli(
   };
   const emit = createEmitter(options.json, deps);
   try {
-    await withRuntimeHostManagedServiceLifecycleLock(
+    await withRuntimeHostManagedServiceDeploymentLock(
       options.clientDataRoot,
-      () => runRuntimeHostSetupLocked(options, deps, emit),
+      () =>
+        withRuntimeHostManagedServiceLifecycleLock(
+          options.clientDataRoot,
+          () => runRuntimeHostSetupLocked(options, deps, emit),
+          SETUP_LOCK_TIMEOUT_MS,
+        ),
       SETUP_LOCK_TIMEOUT_MS,
     );
     return 0;
@@ -179,7 +185,8 @@ async function runRuntimeHostSetupLocked(
       'Managed Runtime Host service did not become ready',
     );
   }
-  await deployment.commit();
+  await deployment.activate();
+  await deployment.cleanup();
 
   emit({ kind: 'progress', phase: 'pairing_client' });
   let paired: Awaited<ReturnType<typeof prepareRuntimeHostAccessCredential>>;
diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts
index d8bf3ef1e..a57595348 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -91,12 +91,13 @@ export function createSystemdUserRuntimeHostService(
       await assertUserSystemd(runSystemctl);
       await assertUserLinger(uid, runLoginctl);
     },
-    install: async (config) => {
+    install: async (config, installOptions) => {
       await validateLaunchFiles(config);
       const previous = await captureSystemdDeployment(context.unitPath, readStatus);
       try {
         await applySystemdDeployment(context, config);
       } catch (error) {
+        if (installOptions?.restoreOnFailure === false) throw error;
         await restoreFailedSystemdDeployment(previous, context, error);
       }
       let rolledBack = false;
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
new file mode 100644
index 000000000..8c633ceeb
--- /dev/null
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -0,0 +1,393 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { execFile } from 'node:child_process';
+import { join } from 'node:path';
+import { truncateUtf8 } from '@maka/core/diagnostic-log';
+import {
+  decodeRuntimeHostServiceManagementFrame,
+  encodeRuntimeHostServiceManagementFrame,
+  RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES,
+  RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES,
+  RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
+  RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+  type RuntimeHostServiceManagementFrame,
+  type RuntimeHostServiceUpdatePhase,
+} from '@maka/runtime-host/operator';
+import {
+  prepareRuntimeHostManagedPackageDeployment,
+  RuntimeHostManagedDeploymentError,
+  type RuntimeHostManagedPackageDeployment,
+} from './runtime-host-managed-deployment.js';
+import {
+  manageRuntimeHostService,
+  resolveRuntimeHostManagedServiceId,
+  RuntimeHostServiceManagerError,
+  withRuntimeHostManagedServiceDeploymentLock,
+  withRuntimeHostManagedServiceLifecycleLock,
+  type RuntimeHostManagedServiceResult,
+  type RuntimeHostManagedServiceTarget,
+  type RuntimeHostServiceBackend,
+} from './runtime-host-service-manager.js';
+import {
+  createPlatformRuntimeHostServiceBackend,
+  runtimeHostServiceSummary,
+} from './runtime-host-service-management-command.js';
+
+const OPERATOR_TIMEOUT_MS = 2 * 60_000;
+const OPERATOR_OUTPUT_MAX_BYTES = 256 * 1024;
+
+export interface RuntimeHostUpdateCliOptions {
+  readonly json: boolean;
+  readonly framed: boolean;
+  readonly clientDataRoot: string;
+  readonly defaultRootPath: string;
+  readonly sourcePackageRoot: string;
+  readonly version: string;
+  readonly expectedTarget: RuntimeHostManagedServiceTarget;
+  readonly allowInterruptActiveTasks?: boolean;
+}
+
+interface RuntimeHostUpdateCliDeps {
+  readonly manage: typeof manageRuntimeHostService;
+  readonly prepareDeployment: typeof prepareRuntimeHostManagedPackageDeployment;
+  readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
+  readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
+  readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+  readonly runOperator: (
+    operatorPath: string,
+    args: readonly string[],
+  ) => Promise<RuntimeHostServiceManagementFrame>;
+  readonly writeOutput: (value: string) => unknown;
+  readonly writeError: (value: string) => unknown;
+}
+
+export async function runManagedRuntimeHostUpdateCli(
+  options: RuntimeHostUpdateCliOptions,
+  overrides: Partial<RuntimeHostUpdateCliDeps> = {},
+): Promise<number> {
+  const deps: RuntimeHostUpdateCliDeps = {
+    manage: manageRuntimeHostService,
+    prepareDeployment: prepareRuntimeHostManagedPackageDeployment,
+    withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock,
+    withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock,
+    createBackend: createPlatformRuntimeHostServiceBackend,
+    runOperator: runManagedRuntimeHostOperator,
+    writeOutput: (value) => process.stdout.write(value),
+    writeError: (value) => process.stderr.write(value),
+    ...overrides,
+  };
+  let deployment: RuntimeHostManagedPackageDeployment | undefined;
+  let cutoverStarted = false;
+  let retired = false;
+  const emit = (frame: RuntimeHostServiceManagementFrame): void => {
+    if (options.framed) {
+      deps.writeOutput(encodeRuntimeHostServiceManagementFrame(frame));
+      return;
+    }
+    if (frame.kind === 'progress') {
+      if (!options.json) deps.writeError(`${humanPhase(frame.phase)}\n`);
+      return;
+    }
+    if (options.json) deps.writeOutput(`${JSON.stringify(frame)}\n`);
+    else if (frame.kind === 'error') deps.writeError(`${frame.error.message}\n`);
+    else {
+      if (frame.action !== 'update') {
+        throw new TypeError('Managed Runtime Host update returned an unrelated result');
+      }
+      deps.writeOutput(`${humanResult(frame)}\n`);
+    }
+  };
+  try {
+    return await deps.withDeploymentLock(options.clientDataRoot, async () => {
+      try {
+        const serviceId = resolveRuntimeHostManagedServiceId(options.clientDataRoot);
+        if (serviceId !== options.expectedTarget.serviceId) {
+          throw new RuntimeHostServiceManagerError(
+            'target_mismatch',
+            'The managed Runtime Host update does not match the expected service identity',
+          );
+        }
+        const backend = deps.createBackend(serviceId);
+        const common = {
+          clientDataRoot: options.clientDataRoot,
+          defaultRootPath: options.defaultRootPath,
+          nodePath: process.execPath,
+          cliPath: join(options.sourcePackageRoot, 'dist', 'cli.js'),
+          expectedTarget: options.expectedTarget,
+        } as const;
+        const status = await deps.manage({ ...common, action: 'status' }, backend);
+        const currentVersion = requireManagedVersion(status);
+        const serviceConfig = status.service.config;
+        if (!serviceConfig?.managedDeploymentRoot) {
+          throw new RuntimeHostServiceManagerError(
+            'invalid_launch',
+            'The Runtime Host service is not owned by a Maka managed deployment',
+          );
+        }
+        emit(progress('checking', currentVersion, options.version));
+        if (currentVersion === options.version && status.service.active) {
+          emit({
+            schemaVersion: 1,
+            kind: 'result',
+            action: 'update',
+            service: runtimeHostServiceSummary(status),
+            ...operatorCapabilities(),
+            update: { kind: 'already_current', version: options.version },
+          });
+          return 0;
+        }
+
+        emit(progress('staging', currentVersion, options.version));
+        deployment = await deps.withLifecycleLock(options.clientDataRoot, () =>
+          deps.prepareDeployment({
+            serviceId,
+            clientDataRoot: options.clientDataRoot,
+            sourcePackageRoot: options.sourcePackageRoot,
+            version: options.version,
+          }),
+        );
+        if (deployment.root !== serviceConfig.managedDeploymentRoot) {
+          throw new RuntimeHostServiceManagerError(
+            'target_mismatch',
+            'The staged Runtime Host package belongs to a different managed deployment',
+          );
+        }
+
+        if (currentVersion !== options.version) {
+          emit(progress('retiring', currentVersion, options.version));
+          const retirement = await deps.runOperator(deployment.operatorPath, [
+            'retire',
+            '--framed',
+            ...expectedTargetArgs(options.expectedTarget),
+            ...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
+          ]);
+          if (retirement.kind === 'error') throw new Error(retirement.error.message);
+          if (retirement.kind !== 'result' || retirement.action !== 'retire') {
+            throw new Error(
+              'The current Runtime Host operator returned an invalid retirement result',
+            );
+          }
+          if (retirement.retirement.kind === 'active_tasks') {
+            await deployment.rollback().catch(() => undefined);
+            deployment = undefined;
+            emit({
+              schemaVersion: 1,
+              kind: 'result',
+              action: 'update',
+              service: retirement.service,
+              ...operatorCapabilities(),
+              update: { kind: 'active_tasks', currentVersion, targetVersion: options.version },
+            });
+            return 1;
+          }
+          retired = true;
+        }
+
+        const targetDeployment = deployment;
+        const updated = await deps.withLifecycleLock(options.clientDataRoot, async () => {
+          const stopped = await deps.manage({ ...common, action: 'status' }, backend);
+          if (
+            requireManagedVersion(stopped) !== currentVersion ||
+            stopped.service.active ||
+            stopped.service.pid !== null ||
+            (stopped.service.state !== 'stopped' && stopped.service.state !== 'failed')
+          ) {
+            throw new RuntimeHostServiceManagerError(
+              'target_mismatch',
+              'The managed Runtime Host changed while the update was preparing its replacement',
+            );
+          }
+          cutoverStarted = true;
+          await targetDeployment.activate();
+          emit(progress('replacing', currentVersion, options.version));
+          const updated = await deps.manage(
+            {
+              ...common,
+              action: 'update',
+              cliPath: targetDeployment.cliPath,
+            },
+            backend,
+          );
+          if (updated.service.installedVersion !== options.version || !updated.service.active) {
+            throw new RuntimeHostServiceManagerError(
+              'update_incomplete',
+              'The replacement Runtime Host did not report the selected package version as ready',
+            );
+          }
+          await targetDeployment.cleanup().catch(() => undefined);
+          return updated;
+        });
+        emit({
+          schemaVersion: 1,
+          kind: 'result',
+          action: 'update',
+          service: runtimeHostServiceSummary(updated),
+          ...operatorCapabilities(),
+          update:
+            currentVersion === options.version
+              ? { kind: 'repaired', version: options.version }
+              : {
+                  kind: 'updated',
+                  previousVersion: currentVersion,
+                  targetVersion: options.version,
+                },
+        });
+        return 0;
+      } catch (error) {
+        if (deployment && !cutoverStarted) await deployment.rollback().catch(() => undefined);
+        if (
+          (retired || cutoverStarted) &&
+          !(error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete')
+        ) {
+          throw new RuntimeHostServiceManagerError(
+            'update_incomplete',
+            `The Runtime Host update may have started its cutover before it failed; retry the exact ${options.version} update to complete recovery`,
+            { cause: error },
+          );
+        }
+        throw error;
+      }
+    });
+  } catch (error) {
+    const code =
+      error instanceof RuntimeHostServiceManagerError ||
+      error instanceof RuntimeHostManagedDeploymentError
+        ? error.code
+        : 'internal_service_error';
+    const message = error instanceof Error ? error.message : String(error);
+    emit({
+      schemaVersion: 1,
+      kind: 'error',
+      action: 'update',
+      error: {
+        code:
+          truncateUtf8(code, RUNTIME_HOST_SERVICE_ERROR_CODE_MAX_BYTES) || 'internal_service_error',
+        message:
+          truncateUtf8(message, RUNTIME_HOST_SERVICE_ERROR_MESSAGE_MAX_BYTES) ||
+          'Runtime Host update failed',
+      },
+    });
+    return 1;
+  }
+}
+
+function progress(
+  phase: RuntimeHostServiceUpdatePhase,
+  currentVersion: string,
+  targetVersion: string,
+): RuntimeHostServiceManagementFrame {
+  return {
+    schemaVersion: 1,
+    kind: 'progress',
+    action: 'update',
+    phase,
+    currentVersion,
+    targetVersion,
+  };
+}
+
+function requireManagedVersion(result: RuntimeHostManagedServiceResult): string {
+  if (!result.service.installed || !result.service.config) {
+    throw new RuntimeHostServiceManagerError(
+      'not_installed',
+      'Runtime Host service is not installed',
+    );
+  }
+  if (!result.service.installedVersion) {
+    throw new RuntimeHostServiceManagerError(
+      'invalid_launch',
+      'The installed Runtime Host package version could not be identified',
+    );
+  }
+  return result.service.installedVersion;
+}
+
+function expectedTargetArgs(target: RuntimeHostManagedServiceTarget): string[] {
+  return [
+    '--expected-service-id',
+    target.serviceId,
+    '--expected-root-path',
+    target.rootPath,
+    '--expected-root-id',
+    target.rootId,
+  ];
+}
+
+function operatorCapabilities(): {
+  readonly operatorCapabilities?: (typeof RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY)[];
+} {
+  return process.env[RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV] ===
+    RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY
+    ? { operatorCapabilities: [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY] }
+    : {};
+}
+
+async function runManagedRuntimeHostOperator(
+  operatorPath: string,
+  args: readonly string[],
+): Promise<RuntimeHostServiceManagementFrame> {
+  return new Promise((resolve, reject) => {
+    execFile(
+      operatorPath,
+      [...args],
+      {
+        encoding: 'utf8',
+        timeout: OPERATOR_TIMEOUT_MS,
+        maxBuffer: OPERATOR_OUTPUT_MAX_BYTES,
+      },
+      (error, stdout, stderr) => {
+        let frame: RuntimeHostServiceManagementFrame | undefined;
+        for (const line of stdout.split(/\r?\n/u)) {
+          frame = decodeRuntimeHostServiceManagementFrame(line) ?? frame;
+        }
+        if (frame) {
+          resolve(frame);
+          return;
+        }
+        const message = stderr.trim();
+        reject(
+          error ?? new Error(message || 'The current Runtime Host operator returned no result'),
+        );
+      },
+    );
+  });
+}
+
+function humanPhase(phase: RuntimeHostServiceUpdatePhase): string {
+  if (phase === 'checking') return 'Checking the managed Runtime Host update...';
+  if (phase === 'staging') return 'Staging the replacement package...';
+  if (phase === 'retiring') return 'Retiring the current Runtime Host...';
+  return 'Starting and verifying the replacement Runtime Host...';
+}
+
+function humanResult(
+  frame: Extract<RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'update' }>,
+): string {
+  if (frame.update.kind === 'already_current') {
+    return `Runtime Host ${frame.update.version} is already installed.`;
+  }
+  if (frame.update.kind === 'active_tasks') {
+    return 'Runtime Host still owns active work. Retry with explicit interruption authority.';
+  }
+  if (frame.update.kind === 'repaired') {
+    return `Runtime Host ${frame.update.version} was restored to a ready state.`;
+  }
+  return `Runtime Host was updated from ${frame.update.previousVersion} to ${frame.update.targetVersion}.`;
+}
diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts
index 93d415745..9a1b0b357 100644
--- a/packages/runtime-host/src/operator/index.ts
+++ b/packages/runtime-host/src/operator/index.ts
@@ -39,6 +39,7 @@ export {
   encodeRuntimeHostServiceManagementFrame,
   type RuntimeHostServiceManagementAction,
   type RuntimeHostServiceManagementFrame,
+  type RuntimeHostServiceUpdatePhase,
   type RuntimeHostOperatorCapability,
   type RuntimeHostServiceSummary,
 } from './service-management-frame.js';
diff --git a/packages/runtime-host/src/operator/service-management-frame.ts b/packages/runtime-host/src/operator/service-management-frame.ts
index b307281ad..45b952464 100644
--- a/packages/runtime-host/src/operator/service-management-frame.ts
+++ b/packages/runtime-host/src/operator/service-management-frame.ts
@@ -38,6 +38,7 @@ const SERVICE_ACTIONS = [
   'stop',
   'restart',
   'retire',
+  'update',
   'logs',
   'uninstall',
 ] as const;
@@ -50,6 +51,7 @@ const NON_RETIRE_SERVICE_ACTIONS = [
   'logs',
   'uninstall',
 ] as const;
+const UPDATE_PHASES = ['checking', 'staging', 'retiring', 'replacing'] as const;
 const SERVICE_STATES = ['not_installed', 'stopped', 'starting', 'running', 'failed'] as const;
 const OPERATOR_CAPABILITIES = [RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY] as const;
 
@@ -106,6 +108,50 @@ const SERVICE_RESULT_COMMON = {
 } as const;
 
 const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([
+  z
+    .object({
+      schemaVersion: z.literal(1),
+      kind: z.literal('progress'),
+      action: z.literal('update'),
+      phase: z.enum(UPDATE_PHASES),
+      currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES),
+      targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES),
+    })
+    .strict(),
+  z
+    .object({
+      ...SERVICE_RESULT_COMMON,
+      action: z.literal('update'),
+      update: z.discriminatedUnion('kind', [
+        z
+          .object({
+            kind: z.literal('already_current'),
+            version: boundedNonEmptyString(FIELD_MAX_BYTES),
+          })
+          .strict(),
+        z
+          .object({
+            kind: z.literal('active_tasks'),
+            currentVersion: boundedNonEmptyString(FIELD_MAX_BYTES),
+            targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES),
+          })
+          .strict(),
+        z
+          .object({
+            kind: z.literal('updated'),
+            previousVersion: boundedNonEmptyString(FIELD_MAX_BYTES),
+            targetVersion: boundedNonEmptyString(FIELD_MAX_BYTES),
+          })
+          .strict(),
+        z
+          .object({
+            kind: z.literal('repaired'),
+            version: boundedNonEmptyString(FIELD_MAX_BYTES),
+          })
+          .strict(),
+      ]),
+    })
+    .strict(),
   z
     .object({
       ...SERVICE_RESULT_COMMON,
@@ -136,6 +182,7 @@ const SERVICE_MANAGEMENT_FRAME_SCHEMA = z.union([
 
 export type RuntimeHostServiceManagementAction = (typeof SERVICE_ACTIONS)[number];
 export type RuntimeHostServiceManagementFrame = z.infer<typeof SERVICE_MANAGEMENT_FRAME_SCHEMA>;
+export type RuntimeHostServiceUpdatePhase = (typeof UPDATE_PHASES)[number];
 export type RuntimeHostOperatorCapability = (typeof OPERATOR_CAPABILITIES)[number];
 export type RuntimeHostServiceSummary = z.infer<typeof SERVICE_SUMMARY_SCHEMA>;
 


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


(maka) 02/03: fix(runtime-host): make managed updates converge

Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/managed-runtime-host-manual-update
in repository https://gitbox.apache.org/repos/asf/maka.git

commit b665c4a3224e29a522aeff30c40d2c069fba02f7
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 23 19:49:25 2026 +0800

    fix(runtime-host): make managed updates converge
    
    Require the loaded service identity and a ready managed process before reporting an update as current. Recover Runtime Host management locks after process exit, preserve operator failure codes, and wait for Desktop to reconnect to the exact Host with interactive SSH fallback when needed.
---
 .../__tests__/runtime-host-desktop-manager.test.ts |   9 +-
 .../main/__tests__/runtime-host-management.test.ts |  27 ++-
 .../__tests__/runtime-host-profile-service.test.ts |  37 +++++
 .../__tests__/runtime-host-ssh-terminal.test.ts    |   2 +
 apps/desktop/src/main/runtime-host-boot.ts         |  32 ++++
 .../src/main/runtime-host-desktop-manager.ts       |  25 +++
 apps/desktop/src/main/runtime-host-management.ts   |  33 ++++
 .../src/main/runtime-host-profile-service.ts       |  15 ++
 apps/desktop/src/main/runtime-host-ssh-terminal.ts |  28 +++-
 .../__tests__/runtime-host-service-manager.test.ts |  54 +++++-
 .../cli/src/__tests__/runtime-host-setup.test.ts   |   1 +
 packages/cli/src/runtime-host-service-manager.ts   |  16 +-
 packages/cli/src/runtime-host-systemd-service.ts   |  27 ++-
 packages/cli/src/runtime-host-update-command.ts    |  16 +-
 packages/storage/package.json                      |   1 +
 .../storage/src/__tests__/file-update-lock.test.ts |  63 +++++++
 .../__tests__/fixtures/file-update-lock-holder.ts  |  28 ++++
 .../src/process-lifetime-file-update-lock.ts       | 185 +++++++++++++++++++++
 18 files changed, 583 insertions(+), 16 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 eec6a5f71..fcca308dd 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
@@ -41,8 +41,8 @@ import {
 } from '../runtime-host-desktop-manager.js';
 
 test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, async () => {
-  const first = candidateHarness({ delayDisconnect: true });
-  const second = candidateHarness();
+  const first = candidateHarness({ delayDisconnect: true, hostEpoch: 'host-before' });
+  const second = candidateHarness({ hostEpoch: 'host-after' });
   const queue = [ready(first.candidate), ready(second.candidate)];
   let starts = 0;
   const interactions: Array<string | undefined> = [];
@@ -71,6 +71,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy
   });
 
   first.disconnect();
+  const replacementReady = owner.waitUntilReady(owner.defaultProfileId(), 'host-before');
   const botMessage = owner.handleBotIncomingMessage({ text: 'hello' } as BotIncomingMessage);
   const stop = owner.stopSession({
     hostId: 'test-host',
@@ -95,7 +96,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy
   assert.equal(second.botMessages, 0);
   assert.deepEqual(second.stoppedSessions, []);
   releaseSecond();
-  await Promise.all([botMessage, stop]);
+  await Promise.all([botMessage, stop, replacementReady]);
 
   assert.equal(first.botMessages, 0);
   assert.equal(second.botMessages, 1);
@@ -815,6 +816,7 @@ function candidateHarness(
     activeTasks?: boolean;
     lifecycleMode?: 'ephemeral' | 'service' | 'remote';
     hostId?: string;
+    hostEpoch?: string;
     finalizeFailures?: Error[];
     disconnectOnFinalizeFailure?: boolean;
     onPrepare?: () => void;
@@ -837,6 +839,7 @@ function candidateHarness(
     hostLifecycleMode: options.lifecycleMode ?? 'ephemeral',
     client: {
       hostId: options.hostId ?? 'test-host',
+      hostEpoch: options.hostEpoch ?? 'test-host-epoch',
       get lifecycleState() {
         return lifecycleState;
       },
diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
index e5612b875..1105f9e4d 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -328,10 +328,12 @@ test('manages only the service identity bound by Desktop onboarding', async () =
   assert.equal(handlers.size, 0);
 });
 
-test('updates the managed profile through its exact package and publishes progress', async () => {
+test('publishes update progress and waits for the managed profile to reconnect', async () => {
   const handlers = new Map<string, (...args: unknown[]) => unknown>();
   const updates: DesktopRuntimeHostSshUpdateInput[] = [];
   const progress: unknown[] = [];
+  const connectionCompletions: unknown[] = [];
+  let failConnection = false;
   const profile = {
     id: 'office',
     name: 'Office',
@@ -385,6 +387,11 @@ test('updates the managed profile through its exact package and publishes progre
       };
     },
     resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }),
+    currentHostEpoch: () => 'host-before-update',
+    awaitUpdatedConnection: async (...args) => {
+      connectionCompletions.push(args);
+      if (failConnection) throw new Error('authentication required');
+    },
     sendProgress: (event) => progress.push(event),
     runAccessManagement: async () => assert.fail('access management is not expected'),
     cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
@@ -404,6 +411,22 @@ test('updates the managed profile through its exact package and publishes progre
     },
   }]);
   assert.deepEqual(progress, [{ profileId: profile.id, phase: 'staging' }]);
+  assert.deepEqual(connectionCompletions, [
+    [profile.id, profile.rootId, 'host-before-update', true],
+  ]);
+
+  failConnection = true;
+  const reconnectFailure = await update({}, profile.id, false);
+  assert.deepEqual(reconnectFailure, {
+    schemaVersion: 1,
+    kind: 'error',
+    action: 'update',
+    error: {
+      code: 'desktop_reconnect_failed',
+      message:
+        'The Runtime Host update completed, but Desktop could not reconnect: authentication required',
+    },
+  });
 });
 
 test('resumes deployment cleanup without invoking the removed operator', async () => {
@@ -567,6 +590,8 @@ function unusedUpdateDependencies() {
   return {
     runUpdate: async (): Promise<never> => assert.fail('update is not expected'),
     resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const),
+    currentHostEpoch: () => undefined,
+    awaitUpdatedConnection: async () => undefined,
     sendProgress: () => undefined,
   };
 }
diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
index abb39cece..3ccb04e3f 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
@@ -225,6 +225,43 @@ test("keeps Local enabled while a new remote Host connects", async () => {
   );
 });
 
+test("reconnects an enabled remote Host with interactive SSH", async () => {
+  const root = await clientRoot();
+  const catalog = createClientRuntimeHostProfileCatalog(root);
+  await catalog.create(MANAGED_PROFILE, "opaque-token");
+  const calls: string[] = [];
+  const service = createDesktopRuntimeHostProfileService({
+    clientDataRoot: root,
+    startup: {
+      preferences: {
+        schemaVersion: 2,
+        defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
+        enabledRemoteProfileIds: [MANAGED_PROFILE.id],
+      },
+      pairingIntents: [],
+      remotes: [{ profile: MANAGED_PROFILE, credential: "opaque-token" }],
+      unavailable: new Map(),
+    },
+    catalog,
+    states: () => [connectingLocal()],
+    enable: async (target, interaction) => {
+      calls.push(`enable:${target.profile.id}:${interaction}`);
+    },
+    disable: async (profileId) => {
+      calls.push(`disable:${profileId}`);
+    },
+    setDefault: () => undefined,
+    finalizePairing: async () => undefined,
+  });
+
+  await service.reconnect(MANAGED_PROFILE.id, MANAGED_PROFILE.rootId);
+
+  assert.deepEqual(calls, [
+    `disable:${MANAGED_PROFILE.id}`,
+    `enable:${MANAGED_PROFILE.id}:terminal`,
+  ]);
+});
+
 test("does not enable the same State Root twice", async () => {
   const root = await clientRoot();
   const startup = await resolveDesktopRuntimeHostStartup(root);
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 6e5844c70..fd688ee6d 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
@@ -328,6 +328,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, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u);
+  harness.pty.emitData('Password: ');
   harness.pty.emitData(
     encodeRuntimeHostServiceManagementFrame({
       schemaVersion: 1,
@@ -366,6 +367,7 @@ test('runs an exact update package and reports progress before an active-work re
   assert.equal(result.kind, 'result');
   assert.equal(result.kind === 'result' ? result.update.kind : undefined, 'active_tasks');
   assert.deepEqual(phases, ['retiring']);
+  assert.deepEqual(harness.events.map(({ kind }) => kind), ['opened', 'data', 'connected']);
   assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u);
   await harness.terminal.close();
 });
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 3fff6a048..74854ce5c 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -210,6 +210,7 @@ import {
 
 await resolveShellEnv();
 
+const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000;
 const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath());
 const userDataDir = app.getPath("userData");
 const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId(
@@ -445,6 +446,37 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
   runServiceManagement: runtimeHostSshTerminal.runServiceManagement,
   runUpdate: runtimeHostSshTerminal.runUpdate,
   resolveUpdatePackage: runtimeHostSetupPackage,
+  currentHostEpoch: (profileId) =>
+    runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch,
+  awaitUpdatedConnection: async (
+    profileId,
+    expectedHostId,
+    previousHostEpoch,
+    replacementExpected,
+  ) => {
+    if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable');
+    const expectedPrevious = replacementExpected ? previousHostEpoch : undefined;
+    try {
+      await runtimeHostManager.waitUntilReady(
+        profileId,
+        expectedPrevious,
+        AbortSignal.timeout(MANAGED_UPDATE_RECONNECT_TIMEOUT_MS),
+      );
+      if (runtimeHostManager.current(profileId)?.hostId !== expectedHostId) {
+        throw new Error('Runtime Host profile changed while its service was updating');
+      }
+    } catch {
+      await runtimeHostProfileService.reconnect(profileId, expectedHostId);
+      const current = runtimeHostManager.current(profileId);
+      if (
+        current?.hostId !== expectedHostId ||
+        !current.candidate ||
+        (expectedPrevious !== undefined && current.candidate.client.hostEpoch === expectedPrevious)
+      ) {
+        throw new Error('Desktop reconnected to an unexpected Runtime Host generation');
+      }
+    }
+  },
   sendProgress: (progress) =>
     mainWindowController.send("runtime-host-management:progress", progress),
   runAccessManagement: runtimeHostSshTerminal.runAccessManagement,
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index 78b606145..e69bd8a40 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -64,6 +64,11 @@ export interface RuntimeHostDesktopManager {
     remote: DesktopRuntimeHostCandidateStartInput['remote'],
   ): Promise<void>;
   disable(profileId: string): Promise<void>;
+  waitUntilReady(
+    profileId: string,
+    previousHostEpoch?: string,
+    signal?: AbortSignal,
+  ): Promise<void>;
   setDefaultProfile(profileId: string): void;
   prepareForUpdate(
     allowInterruptActiveTasks: boolean,
@@ -457,6 +462,26 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     return this.#mutateTarget(profileId, () => this.#disable(profileId));
   }
 
+  async waitUntilReady(
+    profileId: string,
+    previousHostEpoch?: string,
+    signal?: AbortSignal,
+  ): Promise<void> {
+    const target = this.#requireTarget(profileId);
+    let candidate = await this.#waitForReadyCandidate(
+      this.#requireLifecycle(target),
+      undefined,
+      signal,
+    );
+    while (previousHostEpoch !== undefined && candidate.client.hostEpoch === previousHostEpoch) {
+      candidate = await this.#waitForReadyCandidate(
+        this.#requireLifecycle(target),
+        candidate,
+        signal,
+      );
+    }
+  }
+
   async #disable(profileId: string): Promise<void> {
     if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) {
       throw new Error('Local Runtime Host cannot be disabled');
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index a676ce38e..dbce1f433 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -76,6 +76,13 @@ export function createDesktopRuntimeHostManagement(input: {
     onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void,
   ) => Promise<RuntimeHostServiceUpdateTerminalFrame>;
   readonly resolveUpdatePackage: () => DesktopRuntimeHostSetupPackage;
+  readonly currentHostEpoch: (profileId: string) => string | undefined;
+  readonly awaitUpdatedConnection: (
+    profileId: string,
+    expectedHostId: string,
+    previousHostEpoch: string | undefined,
+    replacementExpected: boolean,
+  ) => Promise<void>;
   readonly sendProgress: (progress: DesktopRuntimeHostManagementProgress) => void;
   readonly cleanupManagedDeployment: (
     input: DesktopRuntimeHostSshCleanupInput,
@@ -220,6 +227,7 @@ export function createDesktopRuntimeHostManagement(input: {
     if (managed.state !== 'active' || managed.profile.transport.kind !== 'ssh') {
       throw new Error('This Runtime Host profile is not available for managed updates');
     }
+    const previousHostEpoch = input.currentHostEpoch(profileId);
     const response = await input.runUpdate(
       {
         destination: managed.profile.transport.destination,
@@ -236,6 +244,31 @@ export function createDesktopRuntimeHostManagement(input: {
       },
       (phase) => input.sendProgress({ profileId, phase }),
     );
+    if (
+      response.kind === 'result' &&
+      response.update.kind !== 'active_tasks'
+    ) {
+      try {
+        await input.awaitUpdatedConnection(
+          profileId,
+          managed.profile.rootId,
+          previousHostEpoch,
+          response.update.kind !== 'already_current',
+        );
+      } catch (error) {
+        return {
+          schemaVersion: 1,
+          kind: 'error',
+          action: 'update',
+          error: {
+            code: 'desktop_reconnect_failed',
+            message:
+              'The Runtime Host update completed, but Desktop could not reconnect: ' +
+              (error instanceof Error ? error.message : String(error)),
+          },
+        };
+      }
+    }
     return response.kind === 'result'
       ? {
           ...response,
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index 38b2fcee2..92c9aff15 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -113,6 +113,7 @@ export interface DesktopRuntimeHostProfileService {
   startEnabledProfiles(): Promise<void>;
   resolvePairingRecovery(): Promise<DesktopRuntimeHostProfileSnapshot>;
   setEnabled(profileId: string, enabled: boolean): Promise<DesktopRuntimeHostProfileSnapshot>;
+  reconnect(profileId: string, expectedRootId: string): Promise<void>;
   setDefault(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
   remove(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
 }
@@ -875,6 +876,20 @@ export function createDesktopRuntimeHostProfileService(input: {
         return snapshot();
       });
     },
+    reconnect(profileId, expectedRootId) {
+      return mutateProfiles(async () => {
+        assertPairingComplete(profileId);
+        if (!preferences.enabledRemoteProfileIds.includes(profileId)) {
+          throw new Error('Enable this Runtime Host before reconnecting it');
+        }
+        const target = await catalog.resolve(profileId);
+        if (target.profile.kind !== 'remote' || target.profile.rootId !== expectedRootId) {
+          throw new Error('Runtime Host profile changed before it could reconnect');
+        }
+        await input.disable(profileId);
+        await activateTarget(target, 'terminal');
+      });
+    },
     setDefault(profileId) {
       return mutateProfiles(async () => {
         if (
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index 12fe8ec27..071b8794c 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -60,6 +60,7 @@ interface ActiveTerminal {
   phase: 'connecting' | 'connected';
   revealed: boolean;
   dismissed: boolean;
+  presentationSuppressed: boolean;
   output: string;
 }
 
@@ -207,6 +208,23 @@ export function createDesktopRuntimeHostSshTerminal(input: {
       clearTimeout(terminal.revealTimer);
       terminal.revealTimer = undefined;
     }
+    if (terminal.revealed && !terminal.presentationSuppressed) {
+      input.send('runtime-host-ssh-terminal:event', {
+        kind: 'connected',
+        revision,
+        sessionId: terminal.sessionId,
+      });
+    }
+  }
+  function suppressPresentation(terminal: ActiveTerminal): void {
+    if (active !== terminal || terminal.phase !== 'connecting') return;
+    terminal.presentationSuppressed = true;
+    presentation = undefined;
+    revision += 1;
+    if (terminal.revealTimer !== undefined) {
+      clearTimeout(terminal.revealTimer);
+      terminal.revealTimer = undefined;
+    }
     if (terminal.revealed) {
       input.send('runtime-host-ssh-terminal:event', {
         kind: 'connected',
@@ -249,6 +267,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
       phase: 'connecting',
       revealed: false,
       dismissed: false,
+      presentationSuppressed: false,
       output: '',
     };
     active = terminal;
@@ -257,7 +276,8 @@ export function createDesktopRuntimeHostSshTerminal(input: {
         active !== terminal ||
         terminal.phase !== 'connecting' ||
         terminal.revealed ||
-        terminal.dismissed
+        terminal.dismissed ||
+        terminal.presentationSuppressed
       ) {
         return;
       }
@@ -270,7 +290,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
     pty.onData((data) => {
       if (active !== terminal || terminal.phase !== 'connecting' || terminal.dismissed) return;
       const visible = transformOutput(data);
-      if (!visible) return;
+      if (!visible || terminal.presentationSuppressed) return;
       terminal.output = `${terminal.output}${visible}`.slice(-TERMINAL_OUTPUT_MAX);
       reveal();
       revision += 1;
@@ -396,6 +416,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
     let frame: Frame | undefined;
     let failure: Error | undefined;
     let activeTerminal: ActiveTerminal | undefined;
+    let receivedProgress = false;
     const filter = createFramedOutputFilter({
       prefix: options.prefix,
       pendingMaxBytes: options.pendingMaxBytes,
@@ -408,6 +429,8 @@ export function createDesktopRuntimeHostSshTerminal(input: {
           return;
         }
         if (options.isTerminalFrame && !options.isTerminalFrame(next)) {
+          receivedProgress = true;
+          if (activeTerminal) suppressPresentation(activeTerminal);
           options.onProgress?.(next);
           return;
         }
@@ -430,6 +453,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
     );
     activeTerminal = terminal;
     if (frame) completePresentation(terminal);
+    else if (receivedProgress) suppressPresentation(terminal);
     const wait = await waitForTerminalProcess(process, {
       signal: options.signal,
       timeoutMs: options.timeoutMs ?? input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS,
diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
index 47f5a9826..528bd80e8 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -36,6 +36,7 @@ import {
   decodeRuntimeHostServiceManagementFrame,
   RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
   RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+  type RuntimeHostServiceManagementFrame,
 } from '@maka/runtime-host/operator';
 import { RUNTIME_HOST_RETIREMENT_EXIT_CODE } from '@maka/runtime-host/server';
 import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
@@ -541,6 +542,19 @@ describe('managed Runtime Host service', () => {
       release.backend,
       ready,
     );
+    const releaseConfig = (
+      await manageRuntimeHostService({ ...input(releaseRoot), action: 'status' }, release.backend)
+    ).service.config;
+    assert.ok(releaseConfig);
+    await release.backend.verifyDeployment(releaseConfig);
+    const unit = await readFile(release.unitPath, 'utf8');
+    await writeFile(release.unitPath, `${unit}# stale change\n`);
+    await assert.rejects(
+      release.backend.verifyDeployment(releaseConfig),
+      (error: unknown) =>
+        error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch',
+    );
+    await writeFile(release.unitPath, unit);
     await manageRuntimeHostService(
       { ...input(developmentRoot), action: 'install' },
       development.backend,
@@ -1269,7 +1283,7 @@ describe('managed Runtime Host service', () => {
     assert.equal(config.launch.cliPath, await realpath(targetCli));
   });
 
-  it('updates through the current operator before activating the target service', async () => {
+  it('updates through the current operator and preserves exact update outcomes', async () => {
     const clientDataRoot = '/home/ada/.config/maka';
     const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot);
     const deploymentRoot = '/home/ada/.local/share/Maka/runtime-host-services/service';
@@ -1308,6 +1322,8 @@ describe('managed Runtime Host service', () => {
     let statusReads = 0;
     let observedVersion = '1.0.0';
     let observedState: 'running' | 'stopped' = 'running';
+    let readyChecks = 0;
+    let operatorFailure: Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }> | undefined;
     let insideLifecycle = false;
     let output = '';
     const options = {
@@ -1351,6 +1367,7 @@ describe('managed Runtime Host service', () => {
       runOperator: async (_operatorPath: string, args: readonly string[]) => {
         order.push('retire');
         assert.ok(args.includes('--allow-interrupt-active-tasks'));
+        if (operatorFailure) return operatorFailure;
         return {
           schemaVersion: 1 as const,
           kind: 'result' as const,
@@ -1369,6 +1386,9 @@ describe('managed Runtime Host service', () => {
           retirement: { kind: 'retired' as const, hostEpoch: 'host-1', pid: 42 },
         };
       },
+      verifyReady: async () => {
+        readyChecks += 1;
+      },
       manage: async (input: Parameters<typeof manageRuntimeHostService>[0]) => {
         if (input.action === 'status') {
           statusReads += 1;
@@ -1414,6 +1434,34 @@ describe('managed Runtime Host service', () => {
         : undefined,
       'repaired',
     );
+
+    order.length = 0;
+    statusReads = 0;
+    observedState = 'running';
+    output = '';
+    assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
+    assert.equal(readyChecks, 1);
+    assert.deepEqual(order, []);
+
+    statusReads = 0;
+    observedVersion = '1.0.0';
+    output = '';
+    operatorFailure = {
+      schemaVersion: 1,
+      kind: 'error',
+      action: 'retire',
+      error: { code: 'retirement_failed', message: 'The old Host rejected retirement' },
+    };
+    assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 1);
+    assert.deepEqual(order, ['retire', 'rollback']);
+    const retirementFailure = decodeRuntimeHostServiceManagementFrame(
+      output.trim().split('\n').at(-1) ?? '',
+    );
+    assert.equal(retirementFailure?.kind, 'error');
+    assert.equal(
+      retirementFailure?.kind === 'error' ? retirementFailure.error.code : undefined,
+      'retirement_failed',
+    );
   });
 
   it('rejects invalid Project roots and temporary npx launch paths before deployment', async (t) => {
@@ -1605,6 +1653,8 @@ function createFakeSystemd(unitPath: string): {
             `ActiveState=${active ? 'active' : 'inactive'}`,
             `SubState=${active ? 'running' : 'dead'}`,
             `UnitFileState=${enabled ? 'enabled' : 'disabled'}`,
+            `FragmentPath=${loaded ? unitPath : ''}`,
+            'NeedDaemonReload=no',
             `MainPID=${active ? '4242' : '0'}`,
             'ExecMainStatus=0',
             '',
@@ -1624,6 +1674,7 @@ function createUnusedBackend(): RuntimeHostServiceBackend {
   return {
     preflightInstall: unexpected,
     install: unexpected,
+    verifyDeployment: unexpected,
     status: unexpected,
     start: unexpected,
     stop: unexpected,
@@ -1653,6 +1704,7 @@ function createReadyBackend(): RuntimeHostServiceBackend {
   return {
     preflightInstall: async () => undefined,
     install: async () => ({ rollback: async () => undefined }),
+    verifyDeployment: async () => undefined,
     status,
     start: async () => undefined,
     stop: async () => undefined,
diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts
index 01bab4e9a..3013562ec 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -465,6 +465,7 @@ function unusedBackend(): RuntimeHostServiceBackend {
   return {
     preflightInstall: async () => undefined,
     install: async () => assert.fail('Backend is not expected'),
+    verifyDeployment: async () => assert.fail('Backend is not expected'),
     status: async () => assert.fail('Backend is not expected'),
     start: async () => assert.fail('Backend is not expected'),
     stop: async () => assert.fail('Backend is not expected'),
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index 5b3c9dec1..fb3b77222 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -31,7 +31,7 @@ import {
 } from '@maka/runtime-host/protocol';
 import { connectExistingRuntimeHost } from '@maka/runtime-host/client';
 import { RUNTIME_HOST_SERVICE_LOG_MAX_BYTES } from '@maka/runtime-host/operator';
-import { withFileUpdateLock } from '@maka/storage/file-update-lock';
+import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock';
 import {
   resolveExistingStorageRoot,
   tryAcquireInteractiveRootOwner,
@@ -94,6 +94,7 @@ export interface RuntimeHostServiceBackend {
     config: RuntimeHostManagedServiceConfig,
     options?: { readonly restoreOnFailure?: boolean },
   ): Promise<RuntimeHostServiceDeployment>;
+  verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise<void>;
   status(): Promise<RuntimeHostServiceBackendStatus>;
   start(): Promise<void>;
   stop(): Promise<void>;
@@ -223,7 +224,7 @@ export async function manageRuntimeHostService(
 ): Promise<RuntimeHostManagedServiceResult> {
   const deps: RuntimeHostServiceManagerDeps = {
     allocateLoopbackPort,
-    waitForReady: waitForManagedRuntimeHostReady,
+    waitForReady: verifyRuntimeHostManagedServiceReady,
     prepareRetirement: prepareRuntimeHostRetirement,
     environment: process.env,
     homeDir: homedir(),
@@ -236,7 +237,7 @@ export async function manageRuntimeHostService(
   }
   await mkdir(configDirectory, { recursive: true, mode: 0o700 });
 
-  return withFileUpdateLock(
+  return withProcessLifetimeFileUpdateLock(
     configPath,
     () => manageRuntimeHostServiceLocked(input, backend, deps, configPath),
     SERVICE_OPERATION_LOCK_TIMEOUT_MS,
@@ -249,7 +250,7 @@ export async function withRuntimeHostManagedServiceLifecycleLock<T>(
   timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS,
 ): Promise<T> {
   await mkdir(clientDataRoot, { recursive: true, mode: 0o700 });
-  return withFileUpdateLock(
+  return withProcessLifetimeFileUpdateLock(
     join(clientDataRoot, SERVICE_LIFECYCLE_LOCK_FILE),
     operation,
     timeoutMs,
@@ -262,7 +263,7 @@ export async function withRuntimeHostManagedServiceDeploymentLock<T>(
   timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS,
 ): Promise<T> {
   await mkdir(clientDataRoot, { recursive: true, mode: 0o700 });
-  return withFileUpdateLock(
+  return withProcessLifetimeFileUpdateLock(
     join(clientDataRoot, SERVICE_DEPLOYMENT_LOCK_FILE),
     operation,
     timeoutMs,
@@ -276,7 +277,7 @@ export async function cleanupRuntimeHostManagedDeployment(
   await withRuntimeHostManagedServiceLifecycleLock(input.clientDataRoot, async () => {
     const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot);
     await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
-    await withFileUpdateLock(
+    await withProcessLifetimeFileUpdateLock(
       configPath,
       async () => {
         const serviceId = resolveRuntimeHostManagedServiceId(input.clientDataRoot);
@@ -882,10 +883,11 @@ function isWithin(root: string, candidate: string): boolean {
   );
 }
 
-async function waitForManagedRuntimeHostReady(
+export async function verifyRuntimeHostManagedServiceReady(
   config: RuntimeHostManagedServiceConfig,
   backend: RuntimeHostServiceBackend,
 ): Promise<void> {
+  await backend.verifyDeployment(config);
   const deadline = Date.now() + SERVICE_READY_TIMEOUT_MS;
   let lastFailure = 'not available';
   while (Date.now() < deadline) {
diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts
index a57595348..9c9519ff6 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -109,6 +109,27 @@ export function createSystemdUserRuntimeHostService(
         },
       } satisfies RuntimeHostServiceDeployment;
     },
+    verifyDeployment: async (config) => {
+      await validateLaunchFiles(config);
+      const [status, unit] = await Promise.all([
+        readSystemdStatus(context),
+        readFile(context.unitPath, 'utf8').catch((error: unknown) => {
+          if (isNodeError(error, 'ENOENT')) return null;
+          throw error;
+        }),
+      ]);
+      if (
+        status.loadState !== 'loaded' ||
+        status.fragmentPath !== context.unitPath ||
+        status.needDaemonReload !== 'no' ||
+        unit !== renderSystemdUnit(config)
+      ) {
+        throw new RuntimeHostServiceManagerError(
+          'target_mismatch',
+          'The loaded Runtime Host service does not match its managed deployment',
+        );
+      }
+    },
     status: readStatus,
     start: () => runLifecycleAction(context, 'start'),
     stop: () => runLifecycleAction(context, 'stop'),
@@ -234,6 +255,8 @@ interface SystemdStatus {
   readonly loadState: string;
   readonly activeState: string;
   readonly unitFileState: string;
+  readonly fragmentPath?: string;
+  readonly needDaemonReload?: string;
   readonly mainPid?: string;
   readonly execMainStatus?: string;
 }
@@ -346,7 +369,7 @@ async function readSystemdStatus(context: SystemdUnitContext): Promise<SystemdSt
     result = await context.runSystemctl([
       'show',
       context.unitName,
-      '--property=LoadState,ActiveState,SubState,UnitFileState,MainPID,ExecMainStatus',
+      '--property=LoadState,ActiveState,SubState,UnitFileState,FragmentPath,NeedDaemonReload,MainPID,ExecMainStatus',
       '--no-pager',
     ]);
   } catch (error) {
@@ -365,6 +388,8 @@ async function readSystemdStatus(context: SystemdUnitContext): Promise<SystemdSt
     loadState,
     activeState: properties.get('ActiveState') ?? 'inactive',
     unitFileState: properties.get('UnitFileState') ?? 'disabled',
+    fragmentPath: properties.get('FragmentPath'),
+    needDaemonReload: properties.get('NeedDaemonReload'),
     mainPid: properties.get('MainPID'),
     execMainStatus: properties.get('ExecMainStatus'),
   };
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
index 8c633ceeb..69005cc5d 100644
--- a/packages/cli/src/runtime-host-update-command.ts
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -39,6 +39,7 @@ import {
   manageRuntimeHostService,
   resolveRuntimeHostManagedServiceId,
   RuntimeHostServiceManagerError,
+  verifyRuntimeHostManagedServiceReady,
   withRuntimeHostManagedServiceDeploymentLock,
   withRuntimeHostManagedServiceLifecycleLock,
   type RuntimeHostManagedServiceResult,
@@ -70,6 +71,7 @@ interface RuntimeHostUpdateCliDeps {
   readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
   readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
   readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+  readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady;
   readonly runOperator: (
     operatorPath: string,
     args: readonly string[],
@@ -88,6 +90,7 @@ export async function runManagedRuntimeHostUpdateCli(
     withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock,
     withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock,
     createBackend: createPlatformRuntimeHostServiceBackend,
+    verifyReady: verifyRuntimeHostManagedServiceReady,
     runOperator: runManagedRuntimeHostOperator,
     writeOutput: (value) => process.stdout.write(value),
     writeError: (value) => process.stderr.write(value),
@@ -143,6 +146,7 @@ export async function runManagedRuntimeHostUpdateCli(
         }
         emit(progress('checking', currentVersion, options.version));
         if (currentVersion === options.version && status.service.active) {
+          await deps.verifyReady(serviceConfig, backend);
           emit({
             schemaVersion: 1,
             kind: 'result',
@@ -178,7 +182,17 @@ export async function runManagedRuntimeHostUpdateCli(
             ...expectedTargetArgs(options.expectedTarget),
             ...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
           ]);
-          if (retirement.kind === 'error') throw new Error(retirement.error.message);
+          if (retirement.kind === 'error') {
+            if (retirement.action !== 'retire') {
+              throw new Error(
+                'The current Runtime Host operator returned an unrelated retirement error',
+              );
+            }
+            await deployment.rollback().catch(() => undefined);
+            deployment = undefined;
+            emit({ ...retirement, action: 'update' });
+            return 1;
+          }
           if (retirement.kind !== 'result' || retirement.action !== 'retire') {
             throw new Error(
               'The current Runtime Host operator returned an invalid retirement result',
diff --git a/packages/storage/package.json b/packages/storage/package.json
index f5b58c753..b45746f04 100644
--- a/packages/storage/package.json
+++ b/packages/storage/package.json
@@ -10,6 +10,7 @@
     ".": "./dist/index.js",
     "./credential-store": "./dist/credential-store.js",
     "./file-update-lock": "./dist/file-update-lock.js",
+    "./process-lifetime-file-update-lock": "./dist/process-lifetime-file-update-lock.js",
     "./stable-storage": "./dist/stable-storage.js",
     "./artifact-stores": "./dist/artifact-stores.js",
     "./daily-review-authority": "./dist/daily-review-authority.js",
diff --git a/packages/storage/src/__tests__/file-update-lock.test.ts b/packages/storage/src/__tests__/file-update-lock.test.ts
new file mode 100644
index 000000000..febf6f675
--- /dev/null
+++ b/packages/storage/src/__tests__/file-update-lock.test.ts
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { fork } from 'node:child_process';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { withProcessLifetimeFileUpdateLock } from '../process-lifetime-file-update-lock.js';
+
+test('releases a file update lock when its process is killed', async (t) => {
+  const root = await mkdtemp(join(tmpdir(), 'maka-file-update-lock-'));
+  const targetPath = join(root, 'state');
+  const child = fork(
+    new URL('./fixtures/file-update-lock-holder.js', import.meta.url),
+    [targetPath],
+    { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] },
+  );
+  t.after(async () => {
+    if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
+    await rm(root, { recursive: true, force: true });
+  });
+  await new Promise<void>((resolve, reject) => {
+    child.once('message', (message) => {
+      if (message === 'locked') resolve();
+      else reject(new Error(`Unexpected child message: ${String(message)}`));
+    });
+    child.once('error', reject);
+    child.once('exit', (code, signal) => {
+      reject(new Error(`Lock holder exited before acquisition (${String(code)}, ${signal})`));
+    });
+  });
+
+  child.kill('SIGKILL');
+  await new Promise<void>((resolve) => child.once('exit', () => resolve()));
+
+  let entered = false;
+  await withProcessLifetimeFileUpdateLock(
+    targetPath,
+    async () => {
+      entered = true;
+    },
+    2_000,
+  );
+  assert.equal(entered, true);
+});
diff --git a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts
new file mode 100644
index 000000000..4376b2742
--- /dev/null
+++ b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { withProcessLifetimeFileUpdateLock } from '../../process-lifetime-file-update-lock.js';
+
+const targetPath = process.argv[2];
+if (!targetPath) throw new Error('Missing file update lock target');
+
+await withProcessLifetimeFileUpdateLock(targetPath, async () => {
+  process.send?.('locked');
+  await new Promise<never>(() => setInterval(() => undefined, 1_000));
+});
diff --git a/packages/storage/src/process-lifetime-file-update-lock.ts b/packages/storage/src/process-lifetime-file-update-lock.ts
new file mode 100644
index 000000000..e94526f3b
--- /dev/null
+++ b/packages/storage/src/process-lifetime-file-update-lock.ts
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/// <reference path="./fs-native-extensions.d.ts" />
+
+import { constants as fsConstants } from 'node:fs';
+import { lstat, open, unlink, type FileHandle } from 'node:fs/promises';
+import { tryLock, unlock } from 'fs-native-extensions';
+
+const LOCK_POLL_MS = 25;
+const LOCK_TIMEOUT_MS = 10_000;
+const lockGates = new Map<string, Promise<void>>();
+
+export async function withProcessLifetimeFileUpdateLock<T>(
+  targetPath: string,
+  operation: () => Promise<T>,
+  timeoutMs: number = LOCK_TIMEOUT_MS,
+): Promise<T> {
+  const lockPath = `${targetPath}.lock`;
+  const deadline = Date.now() + timeoutMs;
+  const leasePath = `${targetPath}.lease`;
+  return runWithLockGate(leasePath, deadline, async () => {
+    const lease = await openStableLockFile(leasePath);
+    let leased = false;
+    let markerCreated = false;
+    try {
+      while (!(leased = tryLock(lease.fd))) await waitForLockTurn(lockPath, deadline);
+      await acquireLegacyMarker(lockPath, deadline);
+      markerCreated = true;
+      return await operation();
+    } finally {
+      try {
+        if (markerCreated) await unlink(lockPath).catch(ignoreMissing);
+      } finally {
+        if (leased) releaseLock(lease);
+        await lease.close();
+      }
+    }
+  });
+}
+
+async function runWithLockGate<T>(
+  lockPath: string,
+  deadline: number,
+  operation: () => Promise<T>,
+): Promise<T> {
+  const previous = lockGates.get(lockPath);
+  let release!: () => void;
+  const current = new Promise<void>((resolve) => {
+    release = resolve;
+  });
+  lockGates.set(lockPath, current);
+  try {
+    if (previous) await waitForGate(previous, lockPath, deadline);
+    return await operation();
+  } finally {
+    release();
+    if (lockGates.get(lockPath) === current) lockGates.delete(lockPath);
+  }
+}
+
+async function waitForGate(
+  previous: Promise<void>,
+  lockPath: string,
+  deadline: number,
+): Promise<void> {
+  const remaining = deadline - Date.now();
+  if (remaining <= 0) throw lockTimeout(lockPath);
+  let timer: ReturnType<typeof setTimeout> | undefined;
+  try {
+    await Promise.race([
+      previous.catch(() => undefined),
+      new Promise<never>((_resolve, reject) => {
+        timer = setTimeout(() => reject(lockTimeout(lockPath)), remaining);
+      }),
+    ]);
+  } finally {
+    if (timer !== undefined) clearTimeout(timer);
+  }
+}
+
+async function openStableLockFile(lockPath: string): Promise<FileHandle> {
+  const handle = await open(
+    lockPath,
+    fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW,
+    0o600,
+  );
+  try {
+    await assertStableRegularFile(handle, lockPath);
+    if (process.platform !== 'win32') await handle.chmod(0o600);
+    return handle;
+  } catch (error) {
+    await handle.close();
+    throw error;
+  }
+}
+
+async function acquireLegacyMarker(lockPath: string, deadline: number): Promise<void> {
+  for (;;) {
+    try {
+      const marker = await open(
+        lockPath,
+        fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW,
+        0o600,
+      );
+      await marker.close();
+      return;
+    } catch (error) {
+      if (!isNodeError(error, 'EEXIST')) throw error;
+    }
+    const existing = await lstat(lockPath).catch((error: unknown) => {
+      if (isNodeError(error, 'ENOENT')) return undefined;
+      throw error;
+    });
+    if (!existing) continue;
+    if (existing.isFile() && !existing.isSymbolicLink()) {
+      // Current writers hold the advisory lease before publishing this marker.
+      // Owning the lease proves a remaining regular marker is stale.
+      await unlink(lockPath);
+      continue;
+    }
+    if (!existing.isDirectory() || existing.isSymbolicLink()) {
+      throw new Error(`File update lock path is not a regular marker: ${lockPath}`);
+    }
+    // Older builds use the directory itself as their live lock and publish no
+    // owner identity, so it cannot be safely stolen.
+    await waitForLockTurn(lockPath, deadline);
+  }
+}
+
+async function assertStableRegularFile(handle: FileHandle, lockPath: string): Promise<void> {
+  const [handleStat, pathStat] = await Promise.all([
+    handle.stat({ bigint: true }),
+    lstat(lockPath, { bigint: true }),
+  ]);
+  if (
+    !handleStat.isFile() ||
+    !pathStat.isFile() ||
+    handleStat.dev !== pathStat.dev ||
+    handleStat.ino !== pathStat.ino
+  ) {
+    throw new Error(`File update lease is not one stable regular file: ${lockPath}`);
+  }
+}
+
+async function waitForLockTurn(lockPath: string, deadline: number): Promise<void> {
+  if (Date.now() >= deadline) throw lockTimeout(lockPath);
+  await new Promise<void>((resolve) => setTimeout(resolve, LOCK_POLL_MS));
+}
+
+function lockTimeout(lockPath: string): Error {
+  return new Error(`File update is locked by another process (${lockPath})`);
+}
+
+function releaseLock(handle: FileHandle): void {
+  try {
+    unlock(handle.fd);
+  } catch {
+    // Closing the OS handle is the authoritative release path.
+  }
+}
+
+function ignoreMissing(error: unknown): void {
+  if (!isNodeError(error, 'ENOENT')) throw error;
+}
+
+function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException {
+  return error instanceof Error && 'code' in error && error.code === code;
+}


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