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/09/04 04:38:23 UTC

(maka) branch main updated: fix(desktop): make Runtime Host quit intentional (#4709)

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

M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new d422246e12 fix(desktop): make Runtime Host quit intentional (#4709)
d422246e12 is described below

commit d422246e124723f9e12c1eafe81bf8ccb777fec1
Author: Wang <m4...@gmail.com>
AuthorDate: Fri Sep 4 12:38:17 2026 +0800

    fix(desktop): make Runtime Host quit intentional (#4709)
    
    * fix(desktop): make Runtime Host quit intentional
    
    Prompt before interrupting active local Host work, avoid the shutdown deadline race, and offer identity-bound forced recovery when graceful retirement fails.
    
    Generated-by: Codex
    
    * fix(desktop): fence forced Host retirement
    
    Keep quit recovery inside the coordinator preparation and allow forced termination only while the exact Desktop-owned process remains live.\n\nGenerated-by: Codex
    
    * fix(desktop): bound Host retirement recovery
    
    Fail closed when quit cannot reacquire a reconnecting Host, bound the retirement request, and prevent candidate settlement from waiting forever after forced termination.\n\nGenerated-by: Codex
---
 .../main/__tests__/app-quit-coordinator.test.ts    |  39 +++++-
 .../__tests__/runtime-host-desktop-manager.test.ts | 110 ++++++++++++++++
 .../main/__tests__/runtime-host-quit-copy.test.ts  |  33 +++--
 .../src/main/__tests__/runtime-host-quit.test.ts   | 104 +++++++++++++++
 .../__tests__/runtime-host-ssh-terminal.test.ts    |   2 +-
 apps/desktop/src/main/app-quit-coordinator.ts      |  10 +-
 apps/desktop/src/main/runtime-host-boot.ts         |  39 +++---
 apps/desktop/src/main/runtime-host-client.ts       |   7 +-
 .../src/main/runtime-host-desktop-manager.ts       | 111 +++++++++++++++-
 apps/desktop/src/main/runtime-host-quit-copy.ts    |  75 +++++++++--
 apps/desktop/src/main/runtime-host-quit.ts         | 101 +++++++++++++++
 .../__tests__/registered-host-termination.test.ts  | 121 ++++++++++++++++++
 .../runtime-host/src/client/host-retirement.ts     |  13 +-
 packages/runtime-host/src/client/index.ts          |   4 +
 packages/runtime-host/src/client/launcher.ts       |   2 +-
 .../runtime-host/src/client/reconnect-lifecycle.ts |   8 +-
 .../src/client/registered-host-termination.ts      | 141 +++++++++++++++++++++
 packages/runtime/src/process-tree-terminator.ts    |   6 +-
 18 files changed, 865 insertions(+), 61 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts
index e3bab13b5e..190516dd4e 100644
--- a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts
+++ b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts
@@ -26,7 +26,7 @@ describe('app quit coordinator', () => {
     let resumeQuitCount = 0;
     let preventedCount = 0;
     const coordinator = createAppQuitCoordinator({
-      prepareToQuit: async () => {},
+      prepareToQuit: async () => 'ready' as const,
       cleanup: async () => {},
       focusOrCreateWindow: () => {},
       onPreparationError: () => {},
@@ -64,7 +64,7 @@ describe('app quit coordinator', () => {
       releaseCleanup = resolve;
     });
     const coordinator = createAppQuitCoordinator({
-      prepareToQuit: async () => {},
+      prepareToQuit: async () => 'ready',
       cleanup: async () => {
         cleanupCount += 1;
         await cleanupPending;
@@ -113,7 +113,7 @@ describe('app quit coordinator', () => {
     let focusOrCreateCount = 0;
     let windowCreationSignal: AbortSignal | undefined;
     const coordinator = createAppQuitCoordinator({
-      prepareToQuit: async () => {},
+      prepareToQuit: async () => 'ready',
       cleanup: () => new Promise<void>(() => {}),
       focusOrCreateWindow: (signal) => {
         focusOrCreateCount += 1;
@@ -133,11 +133,39 @@ describe('app quit coordinator', () => {
     assert.equal(windowCreationSignal?.aborted, true);
   });
 
+  it('restores the running app when quit preparation is cancelled', async () => {
+    let cleanupCount = 0;
+    let focusOrCreateCount = 0;
+    let resumeQuitCount = 0;
+    const coordinator = createAppQuitCoordinator({
+      prepareToQuit: async () => 'cancelled',
+      cleanup: async () => {
+        cleanupCount += 1;
+      },
+      focusOrCreateWindow: () => {
+        focusOrCreateCount += 1;
+      },
+      onPreparationError: () => {},
+      onCleanupError: () => {},
+      onWindowCreationError: () => {},
+      resumeQuit: () => {
+        resumeQuitCount += 1;
+      },
+    });
+
+    coordinator.handleBeforeQuit({ preventDefault: () => {} });
+    await flushQuitCoordinator();
+
+    assert.equal(cleanupCount, 0);
+    assert.equal(resumeQuitCount, 0);
+    assert.equal(focusOrCreateCount, 1);
+  });
+
   it('reports window creation failure without leaking an unhandled rejection', async () => {
     const failure = new Error('window load failed');
     const reportedErrors: unknown[] = [];
     const coordinator = createAppQuitCoordinator({
-      prepareToQuit: async () => {},
+      prepareToQuit: async () => 'ready',
       cleanup: async () => {},
       focusOrCreateWindow: async () => {
         throw failure;
@@ -166,6 +194,7 @@ describe('app quit coordinator', () => {
       prepareToQuit: async () => {
         preparationCount += 1;
         if (preparationCount === 1) throw preparationError;
+        return 'ready';
       },
       cleanup: async () => {
         cleanupCount += 1;
@@ -203,7 +232,7 @@ describe('app quit coordinator', () => {
     let focusOrCreateCount = 0;
     let resumeQuitCount = 0;
     const deps = {
-      prepareToQuit: async () => {},
+      prepareToQuit: async () => 'ready' as const,
       cleanup: async () => {
         throw cleanupError;
       },
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 d4f432b498..ad5d984426 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
@@ -215,6 +215,65 @@ test('waits through a reconnect gap before quiescing Host retirement', async ()
   await owner.close();
 });
 
+test('does not treat an in-flight replacement as retired after admission times out', async (t) => {
+  t.mock.timers.enable({ apis: ['setTimeout'] });
+  const first = candidateHarness({
+    ownedProcess: {
+      pid: 42,
+      exited: Promise.resolve({ code: 1, signal: null, stderr: '', stderrTruncated: false }),
+    },
+  });
+  const replacement = candidateHarness();
+  let starts = 0;
+  let reportReconnectStart!: () => void;
+  let releaseReconnect!: () => void;
+  const reconnectStarted = new Promise<void>((resolve) => {
+    reportReconnectStart = resolve;
+  });
+  const reconnectReleased = new Promise<void>((resolve) => {
+    releaseReconnect = resolve;
+  });
+  const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, {
+    startCandidate: async (input) => {
+      starts += 1;
+      if (starts === 1) return ready(first.candidate);
+      reportReconnectStart();
+      const signal = input.signal;
+      assert.ok(signal);
+      await Promise.race([
+        reconnectReleased,
+        new Promise<never>((_resolve, reject) => {
+          signal.addEventListener('abort', () => reject(signal.reason), { once: true });
+        }),
+      ]);
+      return ready(replacement.candidate);
+    },
+    reconnectBackoff: { minMs: 0, maxMs: 0 },
+    waitForHostExit: async () => {},
+  });
+
+  first.disconnect();
+  await reconnectStarted;
+  const retirement = owner.retireOwnedLocalHost('interrupt_active_work');
+  t.mock.timers.tick(5_000);
+  await assert.rejects(
+    retirement,
+    (error: unknown) =>
+      error instanceof DesktopLocalHostRetirementError &&
+      error.facts.pid === undefined &&
+      !error.facts.forceTerminationAvailable,
+  );
+
+  releaseReconnect();
+  await owner.waitUntilReady('local');
+  assert.equal(
+    (await owner.retireOwnedLocalHost('interrupt_active_work')).kind,
+    'retired',
+  );
+  assert.equal(replacement.prepareRetirementCalls, 1);
+  await owner.close();
+});
+
 test('retires the owned ephemeral Host before Desktop quit', async () => {
   const events: string[] = [];
   const current = candidateHarness({
@@ -452,6 +511,57 @@ test('preserves Host facts when authorized retirement is refused', async () => {
   await owner.close();
 });
 
+test('fences replacement launches while force-terminating the exact failed retirement', async () => {
+  const events: string[] = [];
+  const current = candidateHarness({
+    ownedProcess: {
+      pid: 42,
+      exited: new Promise(() => {}),
+    },
+  });
+  const owner = await startRuntimeHostDesktopManager({
+    rootPath: '/test-root',
+    candidateLaunchBarrier: {
+      connect: async () => assert.fail('mocked candidate startup bypasses the barrier'),
+      pause: () => events.push('pause'),
+      retireExcept: async (pid: number) => {
+        events.push(`retire:${pid}`);
+      },
+      resume: () => events.push('resume'),
+      release: () => events.push('release'),
+    },
+  } as unknown as DesktopRuntimeHostCandidateStartInput, {
+    startCandidate: async () => ready(current.candidate),
+    forceTerminateHost: async (identity, stillOwnsProcess) => {
+      assert.deepEqual(identity, {
+        rootPath: '/test-root',
+        rootId: 'test-host',
+        hostEpoch: 'test-host-epoch',
+        pid: 42,
+      });
+      assert.equal(stillOwnsProcess(), true);
+      events.push('terminate');
+      return true;
+    },
+  });
+
+  assert.equal(
+    await owner.forceTerminateOwnedLocalHost({
+      hostId: 'test-host',
+      hostEpoch: 'test-host-epoch',
+      lifecycleMode: 'ephemeral',
+      rootPath: '/test-root',
+      pid: 42,
+      forceTerminationAvailable: true,
+    }),
+    true,
+  );
+  assert.deepEqual(events, ['pause', 'retire:42', 'terminate']);
+  assert.equal((await owner.retireOwnedLocalHost('refuse_active_work')).kind, 'retired');
+  await owner.close();
+  assert.equal(events.at(-1), 'release');
+});
+
 test('resumes candidate launches when candidate retirement fails', async () => {
   const events: string[] = [];
   const current = candidateHarness();
diff --git a/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts
index bf1aab2f04..0b03b5a540 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts
@@ -20,7 +20,10 @@
 import assert from 'node:assert/strict';
 import test from 'node:test';
 import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js';
-import { buildRuntimeHostQuitFailureDialog } from '../runtime-host-quit-copy.js';
+import {
+  buildRuntimeHostActiveQuitDialog,
+  buildRuntimeHostQuitFailureDialog,
+} from '../runtime-host-quit-copy.js';
 
 const failure = new DesktopLocalHostRetirementError(
   {
@@ -29,26 +32,40 @@ const failure = new DesktopLocalHostRetirementError(
     lifecycleMode: 'ephemeral',
     rootPath: '/state/root',
     pid: 4242,
+    forceTerminationAvailable: true,
   },
   { cause: new Error('writer release timed out') },
 );
+const manualFailure = new DesktopLocalHostRetirementError(
+  { ...failure.facts, forceTerminationAvailable: false },
+  { cause: failure.cause },
+);
 
 for (const locale of ['en', 'zh'] as const) {
   test(`quit failure copy exposes actionable Host facts in ${locale}`, () => {
-    const dialog = buildRuntimeHostQuitFailureDialog(failure, locale);
+    const dialog = buildRuntimeHostQuitFailureDialog(manualFailure, locale);
 
-    assert.match(dialog.detail ?? '', /4242/);
-    assert.match(dialog.detail ?? '', /host-epoch/);
-    assert.match(dialog.detail ?? '', /\/state\/root/);
-    assert.match(dialog.detail ?? '', /writer release timed out/);
+    assert.match(dialog.options.detail ?? '', /4242/);
+    assert.match(dialog.options.detail ?? '', /host-epoch/);
+    assert.match(dialog.options.detail ?? '', /\/state\/root/);
+    assert.match(dialog.options.detail ?? '', /writer release timed out/);
   });
 }
 
 test('manual recovery copy names a cross-platform process-management concept', () => {
-  const english = buildRuntimeHostQuitFailureDialog(failure, 'en').detail ?? '';
-  const chinese = buildRuntimeHostQuitFailureDialog(failure, 'zh').detail ?? '';
+  const english = buildRuntimeHostQuitFailureDialog(manualFailure, 'en').options.detail ?? '';
+  const chinese = buildRuntimeHostQuitFailureDialog(manualFailure, 'zh').options.detail ?? '';
 
   assert.match(english, /operating system's process-management tool/);
   assert.match(chinese, /操作系统的进程管理工具/);
   assert.doesNotMatch(`${english}\n${chinese}`, /Activity Monitor|Task Manager|活动监视器|任务管理器/);
 });
+
+test('quit dialogs default to preserving background work', () => {
+  const active = buildRuntimeHostActiveQuitDialog('en');
+  const recovery = buildRuntimeHostQuitFailureDialog(failure, 'en');
+
+  assert.equal(active.decisions[active.options.defaultId ?? -1], 'cancel');
+  assert.equal(recovery.decisions[recovery.options.defaultId ?? -1], 'cancel');
+  assert.deepEqual(recovery.decisions, ['retry', 'force', 'cancel']);
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts b/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts
new file mode 100644
index 0000000000..61f1814bd1
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/runtime-host-quit.test.ts
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { RuntimeHostRetirementMode } from '@maka/runtime-host/client';
+import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js';
+import { prepareRuntimeHostQuit } from '../runtime-host-quit.js';
+
+test('background work requires consent before interruption', async () => {
+  const modes: RuntimeHostRetirementMode[] = [];
+  const owner = {
+    retireOwnedLocalHost: async (mode: RuntimeHostRetirementMode) => {
+      modes.push(mode);
+      return mode === 'refuse_active_work'
+        ? ({ kind: 'active_tasks' } as const)
+        : ({ kind: 'retired', resume: () => {} } as const);
+    },
+    forceTerminateOwnedLocalHost: async () => assert.fail('force termination is not expected'),
+  };
+  const recoverFailure = async () => assert.fail('recovery is not expected');
+
+  assert.equal(
+    await prepareRuntimeHostQuit(owner, {
+      confirmInterrupt: async () => false,
+      recoverFailure,
+    }),
+    'cancelled',
+  );
+  assert.deepEqual(modes, ['refuse_active_work']);
+
+  assert.equal(
+    await prepareRuntimeHostQuit(owner, {
+      confirmInterrupt: async () => true,
+      recoverFailure,
+    }),
+    'ready',
+  );
+  assert.deepEqual(modes, [
+    'refuse_active_work',
+    'refuse_active_work',
+    'interrupt_active_work',
+  ]);
+});
+
+test('failed force termination stays inside the quit recovery decision', async () => {
+  const retirement = new DesktopLocalHostRetirementError(
+    {
+      hostId: 'root-id',
+      hostEpoch: 'host-epoch',
+      lifecycleMode: 'ephemeral',
+      rootPath: '/state/root',
+      pid: 4242,
+      forceTerminationAvailable: true,
+    },
+    { cause: new Error('graceful retirement timed out') },
+  );
+  const recovery: Array<{ canForceTerminate: boolean; cause: string | undefined }> = [];
+  const owner = {
+    retireOwnedLocalHost: async () => Promise.reject(retirement),
+    forceTerminateOwnedLocalHost: async () => {
+      throw new Error('process access denied');
+    },
+  };
+
+  assert.equal(
+    await prepareRuntimeHostQuit(owner, {
+      confirmInterrupt: async () => assert.fail('active-work consent is not expected'),
+      recoverFailure: async (error) => {
+        const canForceTerminate =
+          error instanceof DesktopLocalHostRetirementError &&
+          error.facts.forceTerminationAvailable;
+        recovery.push({
+          canForceTerminate,
+          cause: error instanceof Error && error.cause instanceof Error
+            ? error.cause.message
+            : undefined,
+        });
+        return canForceTerminate ? 'force' : 'cancel';
+      },
+    }),
+    'cancelled',
+  );
+  assert.deepEqual(recovery, [
+    { canForceTerminate: true, cause: 'graceful retirement timed out' },
+    { canForceTerminate: false, cause: 'process access denied' },
+  ]);
+});
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 107f3ebb87..aa1a9a6d97 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
@@ -1093,7 +1093,7 @@ function createHarness(
     terminateProcessTree: async ({ pid, signal, fallback, hasExited, beforeSignal }) => {
       terminatedProcesses.push({ pid, signal });
       await Promise.resolve();
-      if (hasExited?.() || beforeSignal?.() === false) return false;
+      if (hasExited?.() || (beforeSignal && !(await beforeSignal()))) return false;
       fallback?.();
       return true;
     },
diff --git a/apps/desktop/src/main/app-quit-coordinator.ts b/apps/desktop/src/main/app-quit-coordinator.ts
index e6c997da77..5ff773284f 100644
--- a/apps/desktop/src/main/app-quit-coordinator.ts
+++ b/apps/desktop/src/main/app-quit-coordinator.ts
@@ -27,7 +27,7 @@ export interface AppQuitCoordinator {
 }
 
 export interface AppQuitCoordinatorDeps {
-  prepareToQuit(): Promise<void>;
+  prepareToQuit(): Promise<'ready' | 'cancelled'>;
   cleanup(): Promise<void>;
   focusOrCreateWindow(signal: AbortSignal): void | Promise<void>;
   onPreparationError(error: unknown): void;
@@ -75,7 +75,13 @@ export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitC
       void Promise.resolve()
         .then(() => deps.prepareToQuit())
         .then(
-          () => {
+          (preparation) => {
+            if (preparation === 'cancelled') {
+              phase = 'running';
+              windowCreationAbort = new AbortController();
+              focusOrCreateWindow();
+              return;
+            }
             phase = 'cleaning';
             return Promise.resolve()
               .then(() => deps.cleanup())
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 211d8981a3..608ebfd96c 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -193,7 +193,11 @@ import {
   DesktopRuntimeHostStartupRecoveryCancelledError,
   startDesktopRuntimeHostWithRecovery,
 } from "./runtime-host-startup-recovery.js";
-import { buildRuntimeHostQuitFailureDialog } from "./runtime-host-quit-copy.js";
+import {
+  buildRuntimeHostActiveQuitDialog,
+  buildRuntimeHostQuitFailureDialog,
+} from "./runtime-host-quit-copy.js";
+import { prepareRuntimeHostQuit } from "./runtime-host-quit.js";
 import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js";
 import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js";
 import {
@@ -1939,9 +1943,6 @@ function wireLifecycle(): void {
     },
     onPreparationError: (error) => {
       console.error("[runtime-host] quit retirement failed:", error);
-      void showRuntimeHostQuitFailure(error).catch((dialogError) =>
-        console.error("[runtime-host] quit failure dialog failed:", dialogError),
-      );
     },
     onCleanupError: (error) =>
       console.error("[runtime-host] shutdown failed:", error),
@@ -1971,19 +1972,23 @@ function wireLifecycle(): void {
   quitCoordinator.focusOrCreateWindow();
 }
 
-async function prepareRuntimeHostDesktopQuit(): Promise<void> {
-  mainWindowController.browserWindow()?.destroy();
-  const retirement = await runtimeHostManager?.retireOwnedLocalHost(
-    "interrupt_active_work",
-  );
-  if (retirement?.kind === "active_tasks") {
-    throw new Error("Runtime Host refused authorized quit retirement");
-  }
-}
-
-async function showRuntimeHostQuitFailure(error: unknown): Promise<void> {
-  const locale = await desktopLocale.resolve();
-  await showDesktopMessageBox(buildRuntimeHostQuitFailureDialog(error, locale), { locale });
+async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> {
+  const preparation = await prepareRuntimeHostQuit(runtimeHostManager, {
+    confirmInterrupt: async () => {
+      const locale = await desktopLocale.resolve();
+      const dialog = buildRuntimeHostActiveQuitDialog(locale);
+      const { response } = await showDesktopMessageBox(dialog.options, { locale });
+      return dialog.decisions[response] === 'quit';
+    },
+    recoverFailure: async (error) => {
+      const locale = await desktopLocale.resolve();
+      const dialog = buildRuntimeHostQuitFailureDialog(error, locale);
+      const { response } = await showDesktopMessageBox(dialog.options, { locale });
+      return dialog.decisions[response] ?? 'cancel';
+    },
+  });
+  if (preparation === 'ready') mainWindowController.browserWindow()?.destroy();
+  return preparation;
 }
 
 async function closeRuntimeHostDesktop(): Promise<void> {
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 61c6990f11..f548396cb5 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -159,6 +159,7 @@ const decodeStoredMessage = (value: unknown): StoredMessage =>
 const MAX_OPTIMISTIC_ATTEMPTS = 3;
 const MAX_SESSION_REVISION_ATTEMPTS = 8;
 const MAX_PRICING_SNAPSHOT_ATTEMPTS = 3;
+const RUNTIME_HOST_RETIREMENT_TIMEOUT_MS = 5_000;
 
 export type DesktopSessionConfigurationPatch = SessionConfigurationPatch;
 
@@ -1322,7 +1323,11 @@ export class DesktopRuntimeHostClient {
   prepareHostRetirement(
     mode: RuntimeHostRetirementMode,
   ): Promise<RuntimeHostRetirementPreparation> {
-    return prepareConnectedRuntimeHostRetirement(this.connection, mode);
+    return prepareConnectedRuntimeHostRetirement(
+      this.connection,
+      mode,
+      RUNTIME_HOST_RETIREMENT_TIMEOUT_MS,
+    );
   }
 
   stopTurn(
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index b514afaec1..6731a4aae2 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -21,6 +21,7 @@ import { randomUUID } from 'node:crypto';
 import type { BotIncomingMessage } from '@maka/runtime/bots';
 import {
   abortable,
+  forceTerminateRegisteredRuntimeHost,
   RuntimeHostOperationError,
   RuntimeHostPermanentReconnectError,
   RuntimeHostRequestInterruptedError,
@@ -96,6 +97,7 @@ export interface RuntimeHostDesktopManager {
   runManagedLocalHostChange<T>(change: () => Promise<T>): Promise<T>;
   setDefaultProfile(profileId: string): void;
   retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise<DesktopLocalHostRetirement>;
+  forceTerminateOwnedLocalHost(facts: DesktopLocalHostRetirementFacts): Promise<boolean>;
   close(): Promise<void>;
 }
 
@@ -144,6 +146,7 @@ export interface DesktopLocalHostRetirementFacts {
   readonly lifecycleMode: 'ephemeral';
   readonly rootPath: string;
   readonly pid?: number;
+  readonly forceTerminationAvailable: boolean;
 }
 
 export class DesktopLocalHostRetirementError extends Error {
@@ -184,6 +187,7 @@ export class RuntimeHostPairingFinalizationInterruptedError extends Error {
 export type RuntimeHostGuestAccessFinalization = 'ready' | 'reconnecting';
 
 const DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS = 30_000;
+const LOCAL_HOST_RETIREMENT_ADMISSION_TIMEOUT_MS = 5_000;
 
 export type RuntimeHostRestartableConflict = Extract<
   DesktopRuntimeHostCandidateStartResult,
@@ -242,6 +246,7 @@ export async function startRuntimeHostDesktopManager(
     onFatalError?: (error: Error, target: ResolvedRuntimeHostProfile) => void;
     upgradePrompts?: RuntimeHostUpgradePrompts;
     waitForHostExit?: (pid: number) => Promise<void>;
+    forceTerminateHost?: typeof forceTerminateRegisteredRuntimeHost;
     waitForHostRetirement?: (
       registration: HostRegistration,
       signal: AbortSignal,
@@ -265,6 +270,7 @@ export async function startRuntimeHostDesktopManager(
     options.onFatalError ?? ((error) => console.error('[runtime-host] reconnect failed:', error)),
     options.upgradePrompts,
     options.waitForHostExit ?? waitForProcessExit,
+    options.forceTerminateHost ?? forceTerminateRegisteredRuntimeHost,
     options.waitForHostRetirement ?? waitForProcessRetirement,
     options.resolveLocalHostReplacement,
     options.recoverLocalHost,
@@ -303,6 +309,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     ) => void,
     private readonly upgradePrompts: RuntimeHostUpgradePrompts | undefined,
     private readonly waitForHostExit: (pid: number) => Promise<void>,
+    private readonly forceTerminateHost: typeof forceTerminateRegisteredRuntimeHost,
     private readonly waitForHostRetirement: (
       registration: HostRegistration,
       signal: AbortSignal,
@@ -751,6 +758,61 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     return result;
   }
 
+  async forceTerminateOwnedLocalHost(
+    facts: DesktopLocalHostRetirementFacts,
+  ): Promise<boolean> {
+    if (this.#localHostRetirement) return true;
+    const target = this.#targets.get(LOCAL_RUNTIME_HOST_PROFILE.id);
+    const last = target?.lastCandidate;
+    const ownedProcess = last?.ownedProcess;
+    if (
+      !facts.forceTerminationAvailable ||
+      !last ||
+      last.hostId !== facts.hostId ||
+      last.hostEpoch !== facts.hostEpoch ||
+      last.ownership !== 'owned_ephemeral' ||
+      facts.pid === undefined ||
+      !ownedProcess ||
+      ownedProcess.pid !== facts.pid ||
+      ownedProcess.state === 'unknown'
+    ) {
+      return false;
+    }
+    const stillOwnsProcess = () =>
+      !this.#closed &&
+      target?.lastCandidate === last &&
+      last.ownedProcess === ownedProcess &&
+      ownedProcess.state === 'running';
+    const barrier = this.#baseInput.candidateLaunchBarrier;
+    let paused = false;
+    let retained = false;
+    try {
+      barrier?.pause();
+      paused = barrier !== undefined;
+      await barrier?.retireExcept(facts.pid);
+      const terminated =
+        ownedProcess.state === 'exited' ||
+        await this.forceTerminateHost(
+          {
+            rootPath: facts.rootPath,
+            rootId: facts.hostId,
+            hostEpoch: facts.hostEpoch,
+            pid: facts.pid,
+          },
+          stillOwnsProcess,
+        );
+      if (!terminated && (target.lastCandidate !== last || ownedProcess.state !== 'exited')) {
+        return false;
+      }
+      target.lastCandidate = undefined;
+      this.#completeLocalHostRetirement(() => barrier?.resume());
+      retained = true;
+      return true;
+    } finally {
+      if (paused && !retained) barrier?.resume();
+    }
+  }
+
   async #retireOwnedLocalHost(
     mode: RuntimeHostRetirementMode,
   ): Promise<DesktopLocalHostRetirement> {
@@ -761,12 +823,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     let quiescence: Awaited<
       ReturnType<RuntimeHostReconnectLifecycle<DesktopRuntimeHostCandidate>['quiesce']>
     >;
+    const admissionAbort = new AbortController();
+    const admissionTimeout = setTimeout(
+      () => admissionAbort.abort(new Error('Runtime Host did not reconnect before retirement')),
+      LOCAL_HOST_RETIREMENT_ADMISSION_TIMEOUT_MS,
+    );
     try {
-      quiescence = await lifecycle.quiesce();
+      quiescence = await lifecycle.quiesce(admissionAbort.signal);
     } catch (error) {
       const terminal = this.#unavailableLocalHostRetirement(target, error);
       if (terminal) return terminal;
+      if (admissionAbort.signal.aborted) {
+        throw this.#localHostRetirementError(target, error) ?? error;
+      }
       throw error;
+    } finally {
+      clearTimeout(admissionTimeout);
     }
     let hostPid = quiescence.current.hostPid;
     let launchBarrierPaused = false;
@@ -801,6 +873,14 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
       target.lastCandidate = undefined;
       return this.#completeLocalHostRetirement(resume);
     } catch (error) {
+      const last = target.lastCandidate;
+      const forceTerminationAvailable =
+        hostPid !== undefined &&
+        last?.hostId === quiescence.current.client.hostId &&
+        last.hostEpoch === quiescence.current.client.hostEpoch &&
+        last.ownership === 'owned_ephemeral' &&
+        last.ownedProcess?.pid === hostPid &&
+        last.ownedProcess.state === 'running';
       resume();
       throw new DesktopLocalHostRetirementError(
         {
@@ -809,6 +889,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
           lifecycleMode: 'ephemeral',
           rootPath: this.#baseInput.rootPath,
           ...(hostPid === undefined ? {} : { pid: hostPid }),
+          forceTerminationAvailable,
         },
         { cause: error },
       );
@@ -820,10 +901,27 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     cause: unknown = target.state.readiness === 'unavailable' ? target.state.error : undefined,
   ): DesktopLocalHostRetirement | undefined {
     if (target.state.readiness !== 'unavailable') return undefined;
+    return this.#retirementWithoutCurrentHost(target, cause);
+  }
+
+  #retirementWithoutCurrentHost(
+    target: DesktopRuntimeHostTargetGeneration,
+    cause: unknown,
+  ): DesktopLocalHostRetirement {
+    const failure = this.#localHostRetirementError(target, cause);
+    if (!failure || target.lastCandidate?.ownedProcess?.state === 'exited') {
+      return { kind: 'not_owned' };
+    }
+    throw failure;
+  }
+
+  #localHostRetirementError(
+    target: DesktopRuntimeHostTargetGeneration,
+    cause: unknown,
+  ): DesktopLocalHostRetirementError | undefined {
     const last = target.lastCandidate;
-    if (!last || last.ownership !== 'owned_ephemeral') return { kind: 'not_owned' };
-    if (last.ownedProcess?.state === 'exited') return { kind: 'not_owned' };
-    throw new DesktopLocalHostRetirementError(
+    if (!last || last.ownership !== 'owned_ephemeral') return undefined;
+    return new DesktopLocalHostRetirementError(
       {
         hostId: last.hostId,
         hostEpoch: last.hostEpoch,
@@ -832,6 +930,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
         ...(last.ownedProcess?.state === 'running'
           ? { pid: last.ownedProcess.pid }
           : {}),
+        forceTerminationAvailable: last.ownedProcess?.state === 'running',
       },
       { cause: cause instanceof Error ? cause : new Error(String(cause)) },
     );
@@ -1379,7 +1478,9 @@ async function waitForProcessRetirement(
 }
 
 async function waitForProcessExit(pid: number): Promise<void> {
-  const deadline = Date.now() + 10_000;
+  // The Host owns a 10-second graceful-shutdown deadline. Keep a separate
+  // observation margin so Desktop cannot race the Host's final process.exit.
+  const deadline = Date.now() + 12_000;
   while (isProcessAlive(pid)) {
     if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before retirement');
     await new Promise<void>((resolve) => setTimeout(resolve, 50));
diff --git a/apps/desktop/src/main/runtime-host-quit-copy.ts b/apps/desktop/src/main/runtime-host-quit-copy.ts
index f9bfff703d..26db6a37e4 100644
--- a/apps/desktop/src/main/runtime-host-quit-copy.ts
+++ b/apps/desktop/src/main/runtime-host-quit-copy.ts
@@ -20,19 +20,48 @@
 import type { UiLocale } from '@maka/core/ui-locale';
 import type { MessageBoxOptions } from 'electron';
 import { DesktopLocalHostRetirementError } from './runtime-host-desktop-manager.js';
+import type { RuntimeHostQuitFailureDecision } from './runtime-host-quit.js';
+
+export interface RuntimeHostQuitDialog<Decision extends string> {
+  readonly options: MessageBoxOptions;
+  readonly decisions: readonly Decision[];
+}
+
+export type RuntimeHostActiveQuitDecision = 'quit' | 'cancel';
+
+export function buildRuntimeHostActiveQuitDialog(
+  locale: UiLocale,
+): RuntimeHostQuitDialog<RuntimeHostActiveQuitDecision> {
+  const copy = COPY[locale];
+  return {
+    options: {
+      type: 'warning',
+      title: copy.activeTitle,
+      message: copy.activeMessage,
+      detail: copy.activeDetail,
+      buttons: [copy.stopAndQuit, copy.keepRunning],
+      defaultId: 1,
+      cancelId: 1,
+      noLink: true,
+    },
+    decisions: ['quit', 'cancel'],
+  };
+}
 
 export function buildRuntimeHostQuitFailureDialog(
   error: unknown,
   locale: UiLocale,
-): MessageBoxOptions {
+): RuntimeHostQuitDialog<RuntimeHostQuitFailureDecision> {
   const retirement = error instanceof DesktopLocalHostRetirementError ? error : undefined;
+  const canForceTerminate = retirement?.facts.forceTerminationAvailable === true;
   const copy = COPY[locale];
   const details: string[] = [copy.detail];
   if (retirement) {
     details.push(`State Root: ${retirement.facts.rootPath}`);
     details.push(`Host epoch: ${retirement.facts.hostEpoch}`);
     if (retirement.facts.pid !== undefined) {
-      details.push(copy.process(retirement.facts.pid), copy.manual);
+      details.push(copy.process(retirement.facts.pid));
+      details.push(canForceTerminate ? copy.forceWarning : copy.manual);
     }
   }
   const cause = error instanceof Error && error.cause instanceof Error
@@ -41,35 +70,59 @@ export function buildRuntimeHostQuitFailureDialog(
       ? error.message
       : String(error);
   details.push(`${copy.cause}: ${cause}`);
+  const decisions: RuntimeHostQuitFailureDecision[] = canForceTerminate
+    ? ['retry', 'force', 'cancel']
+    : ['retry', 'cancel'];
   return {
-    type: 'error',
-    title: copy.title,
-    message: copy.message,
-    detail: details.join('\n'),
-    buttons: [copy.button],
-    defaultId: 0,
-    noLink: true,
+    options: {
+      type: 'error',
+      title: copy.title,
+      message: copy.message,
+      detail: details.join('\n'),
+      buttons: canForceTerminate
+        ? [copy.retry, copy.forceQuit, copy.keepRunning]
+        : [copy.retry, copy.keepRunning],
+      defaultId: decisions.length - 1,
+      cancelId: decisions.length - 1,
+      noLink: true,
+    },
+    decisions,
   };
 }
 
 const COPY = {
   en: {
+    activeTitle: 'Maka is still working',
+    activeMessage: 'Background work is still running.',
+    activeDetail:
+      'Quitting now stops the Runtime Host and may interrupt active executions or scheduled background work.',
+    stopAndQuit: 'Stop Work and Quit',
+    keepRunning: 'Keep Maka Running',
     title: 'Unable to quit Maka safely',
     message: 'The local Runtime Host could not stop safely. Maka is still running.',
     detail: 'Quit was cancelled. Try again, or inspect diagnostics if the problem persists.',
     process: (pid: number) => `Runtime Host process PID: ${pid}`,
     manual:
       "If retry still fails, confirm that no execution must be preserved before stopping this PID with the operating system's process-management tool.",
+    forceWarning: 'Force quitting can discard in-flight external work that has not settled.',
     cause: 'Cause',
-    button: 'OK',
+    retry: 'Retry Quit',
+    forceQuit: 'Force Quit Maka',
   },
   zh: {
+    activeTitle: 'Maka 正在后台工作',
+    activeMessage: '仍有后台工作正在运行。',
+    activeDetail: '现在退出会停止 Runtime Host,并可能中断正在执行或等待运行的后台任务。',
+    stopAndQuit: '停止任务并退出',
+    keepRunning: '继续运行 Maka',
     title: '无法安全退出 Maka',
     message: '本地 Runtime Host 未能安全停止,Maka 仍在运行。',
     detail: '退出已取消。请重试;如果问题持续存在,请查看诊断信息。',
     process: (pid: number) => `Runtime Host 进程 PID:${pid}`,
     manual: '如果重试仍然失败,请先确认没有需要保留的执行,再通过操作系统的进程管理工具停止该 PID。',
+    forceWarning: '强制退出可能丢弃尚未完成的外部工作。',
     cause: '原因',
-    button: '好',
+    retry: '重试退出',
+    forceQuit: '强制退出 Maka',
   },
 } as const;
diff --git a/apps/desktop/src/main/runtime-host-quit.ts b/apps/desktop/src/main/runtime-host-quit.ts
new file mode 100644
index 0000000000..b45529e1a7
--- /dev/null
+++ b/apps/desktop/src/main/runtime-host-quit.ts
@@ -0,0 +1,101 @@
+/*
+ * 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 {
+  DesktopLocalHostRetirementError,
+  type RuntimeHostDesktopManager,
+} from './runtime-host-desktop-manager.js';
+
+type RetirementOwner = Pick<
+  RuntimeHostDesktopManager,
+  'retireOwnedLocalHost' | 'forceTerminateOwnedLocalHost'
+>;
+
+export type RuntimeHostQuitFailureDecision = 'retry' | 'force' | 'cancel';
+
+export interface RuntimeHostQuitPrompts {
+  confirmInterrupt(): Promise<boolean>;
+  recoverFailure(error: unknown): Promise<RuntimeHostQuitFailureDecision>;
+}
+
+export async function prepareRuntimeHostQuit(
+  owner: RetirementOwner | undefined,
+  prompts: RuntimeHostQuitPrompts,
+): Promise<'ready' | 'cancelled'> {
+  if (!owner) return 'ready';
+  for (;;) {
+    try {
+      const guarded = await owner.retireOwnedLocalHost('refuse_active_work');
+      if (guarded.kind !== 'active_tasks') return 'ready';
+      if (!(await prompts.confirmInterrupt())) return 'cancelled';
+      const authorized = await owner.retireOwnedLocalHost('interrupt_active_work');
+      if (authorized.kind === 'active_tasks') {
+        throw new Error('Runtime Host refused authorized quit retirement');
+      }
+      return 'ready';
+    } catch (error) {
+      const recovery = await recoverRuntimeHostQuit(owner, prompts, error);
+      if (recovery !== 'retry') return recovery;
+    }
+  }
+}
+
+async function recoverRuntimeHostQuit(
+  owner: RetirementOwner,
+  prompts: RuntimeHostQuitPrompts,
+  error: unknown,
+): Promise<'ready' | 'retry' | 'cancelled'> {
+  let currentError = error;
+  for (;;) {
+    const retirement = forceTerminableRetirement(currentError);
+    const decision = await prompts.recoverFailure(currentError);
+    if (decision === 'cancel') return 'cancelled';
+    if (decision === 'retry') return 'retry';
+    if (!retirement) throw new Error('Force termination was selected without Host authority');
+    try {
+      if (await owner.forceTerminateOwnedLocalHost(retirement.facts)) return 'ready';
+      currentError = forceTerminationError(
+        retirement,
+        new Error('The Runtime Host identity changed or forced termination failed'),
+      );
+    } catch (cause) {
+      currentError = forceTerminationError(retirement, cause);
+    }
+  }
+}
+
+function forceTerminableRetirement(
+  error: unknown,
+): DesktopLocalHostRetirementError | undefined {
+  return error instanceof DesktopLocalHostRetirementError &&
+    error.facts.pid !== undefined &&
+    error.facts.forceTerminationAvailable
+    ? error
+    : undefined;
+}
+
+function forceTerminationError(
+  retirement: DesktopLocalHostRetirementError,
+  cause: unknown,
+): DesktopLocalHostRetirementError {
+  return new DesktopLocalHostRetirementError(
+    { ...retirement.facts, forceTerminationAvailable: false },
+    { cause: cause instanceof Error ? cause : new Error(String(cause)) },
+  );
+}
diff --git a/packages/runtime-host/src/__tests__/registered-host-termination.test.ts b/packages/runtime-host/src/__tests__/registered-host-termination.test.ts
new file mode 100644
index 0000000000..98f073231b
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/registered-host-termination.test.ts
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import test from 'node:test';
+import {
+  prepareStorageRootControlDirectory,
+  resolveStorageRoot,
+} from '@maka/storage/root-authority';
+import { forceTerminateRegisteredRuntimeHostWithDependencies } from '../client/registered-host-termination.js';
+import { writeHostRegistration } from '../control/registration.js';
+import {
+  RUNTIME_HOST_COMPATIBILITY_EPOCH,
+  RUNTIME_HOST_PROTOCOL_VERSION,
+  RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
+  type HostRegistration,
+} from '../protocol/index.js';
+
+test('forced termination remains bound to the registered Host identity', async (t) => {
+  const rootPath = await mkdtemp(join(tmpdir(), 'maka-host-termination-'));
+  t.after(() => rm(rootPath, { recursive: true, force: true }));
+  const capability = await resolveStorageRoot({ path: rootPath, kind: 'interactive' });
+  const { controlDirectory } = await prepareStorageRootControlDirectory(capability);
+  const identity = {
+    rootPath,
+    rootId: capability.rootId,
+    hostEpoch: 'expected-epoch',
+    pid: 4242,
+  };
+  const registration: HostRegistration = {
+    kind: 'maka-runtime-host',
+    schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
+    rootId: capability.rootId,
+    hostEpoch: identity.hostEpoch,
+    endpoint: join(rootPath, 'runtime-host.sock'),
+    protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
+    protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
+    compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
+    compositionId: 'maka.interactive',
+    compositionRevision: 'test',
+    lifecycleMode: 'ephemeral',
+    state: 'ready',
+    pid: identity.pid,
+    createdAt: new Date(0).toISOString(),
+  };
+  let alive = true;
+  let terminated = 0;
+  let replaceBeforeSignal = false;
+  let stillOwnsProcess = true;
+  let releaseOwnershipBeforeSignal = false;
+  const dependencies = {
+    isProcessAlive: () => alive,
+    settleMs: 0,
+    terminateProcess: async (options: { beforeSignal?: () => boolean | Promise<boolean> }) => {
+      if (replaceBeforeSignal) {
+        await writeHostRegistration(controlDirectory, { ...registration, hostEpoch: 'successor' });
+      }
+      if (releaseOwnershipBeforeSignal) stillOwnsProcess = false;
+      if (options.beforeSignal && !(await options.beforeSignal())) return false;
+      terminated += 1;
+      alive = false;
+      return true;
+    },
+  };
+
+  await writeHostRegistration(controlDirectory, registration);
+  replaceBeforeSignal = true;
+  assert.equal(
+    await forceTerminateRegisteredRuntimeHostWithDependencies(
+      identity,
+      () => stillOwnsProcess,
+      dependencies,
+    ),
+    false,
+  );
+  assert.equal(terminated, 0);
+
+  await writeHostRegistration(controlDirectory, registration);
+  replaceBeforeSignal = false;
+  releaseOwnershipBeforeSignal = true;
+  assert.equal(
+    await forceTerminateRegisteredRuntimeHostWithDependencies(
+      identity,
+      () => stillOwnsProcess,
+      dependencies,
+    ),
+    false,
+  );
+  assert.equal(terminated, 0);
+
+  stillOwnsProcess = true;
+  releaseOwnershipBeforeSignal = false;
+  assert.equal(
+    await forceTerminateRegisteredRuntimeHostWithDependencies(
+      identity,
+      () => stillOwnsProcess,
+      dependencies,
+    ),
+    true,
+  );
+  assert.equal(terminated, 1);
+});
diff --git a/packages/runtime-host/src/client/host-retirement.ts b/packages/runtime-host/src/client/host-retirement.ts
index 4ea0bf52b4..5696f529fa 100644
--- a/packages/runtime-host/src/client/host-retirement.ts
+++ b/packages/runtime-host/src/client/host-retirement.ts
@@ -33,9 +33,14 @@ export type RuntimeHostRetirementPreparation = OperationOutput<'host.upgrade.pre
 export function prepareConnectedRuntimeHostRetirement(
   connection: RuntimeHostConnection,
   mode: RuntimeHostRetirementMode,
+  timeoutMs?: number,
 ): Promise<RuntimeHostRetirementPreparation> {
-  return connection.request('host.upgrade.prepare', {
-    expectedHostEpoch: connection.hostEpoch,
-    allowInterruptActiveTasks: mode === 'interrupt_active_work',
-  });
+  return connection.request(
+    'host.upgrade.prepare',
+    {
+      expectedHostEpoch: connection.hostEpoch,
+      allowInterruptActiveTasks: mode === 'interrupt_active_work',
+    },
+    timeoutMs,
+  );
 }
diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts
index 13bac9ed7c..134aede060 100644
--- a/packages/runtime-host/src/client/index.ts
+++ b/packages/runtime-host/src/client/index.ts
@@ -44,6 +44,10 @@ export {
   type RuntimeHostRetirementMode,
   type RuntimeHostRetirementPreparation,
 } from './host-retirement.js';
+export {
+  forceTerminateRegisteredRuntimeHost,
+  type RegisteredRuntimeHostIdentity,
+} from './registered-host-termination.js';
 export {
   LOCAL_RUNTIME_HOST_PROFILE,
   RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES,
diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts
index 2b80ee1266..821a402d01 100644
--- a/packages/runtime-host/src/client/launcher.ts
+++ b/packages/runtime-host/src/client/launcher.ts
@@ -139,7 +139,7 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput):
         const result = await within(exited, timeoutMs);
         if (result) return result.code === 0 && result.signal === null;
         child.kill('SIGKILL');
-        await exited;
+        await within(exited, timeoutMs);
         return false;
       },
     })),
diff --git a/packages/runtime-host/src/client/reconnect-lifecycle.ts b/packages/runtime-host/src/client/reconnect-lifecycle.ts
index eedbf6f6c7..c6cab25a49 100644
--- a/packages/runtime-host/src/client/reconnect-lifecycle.ts
+++ b/packages/runtime-host/src/client/reconnect-lifecycle.ts
@@ -51,7 +51,7 @@ export interface RuntimeHostReconnectLifecycle<T extends RuntimeHostReconnectRes
   subscribe(listener: (current: T | undefined) => void): () => void;
   wake(): void;
   suspend(): Promise<RuntimeHostReconnectSuspension<T>>;
-  quiesce(): Promise<RuntimeHostReconnectQuiescence<T>>;
+  quiesce(signal?: AbortSignal): Promise<RuntimeHostReconnectQuiescence<T>>;
   close(): Promise<void>;
 }
 
@@ -251,7 +251,8 @@ class RuntimeHostReconnectLifecycleImpl<T extends RuntimeHostReconnectResource>
     return this.#suspension(this.#current);
   }
 
-  async quiesce(): Promise<RuntimeHostReconnectQuiescence<T>> {
+  async quiesce(signal?: AbortSignal): Promise<RuntimeHostReconnectQuiescence<T>> {
+    signal?.throwIfAborted();
     while (!this.#current) {
       if (this.#closed || this.#terminalError) {
         throw new Error('Runtime Host reconnect lifecycle is closed');
@@ -259,8 +260,9 @@ class RuntimeHostReconnectLifecycleImpl<T extends RuntimeHostReconnectResource>
       if (this.#quiesced) {
         throw new Error('Runtime Host reconnect lifecycle is already quiesced');
       }
-      await this.waitForCurrent();
+      await this.waitForCurrent(undefined, signal);
     }
+    signal?.throwIfAborted();
     if (this.#closed || this.#terminalError) {
       throw new Error('Runtime Host reconnect lifecycle is closed');
     }
diff --git a/packages/runtime-host/src/client/registered-host-termination.ts b/packages/runtime-host/src/client/registered-host-termination.ts
new file mode 100644
index 0000000000..4832b08c99
--- /dev/null
+++ b/packages/runtime-host/src/client/registered-host-termination.ts
@@ -0,0 +1,141 @@
+/*
+ * 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 { terminateProcessTree } from '@maka/runtime/process-tree-terminator';
+import {
+  prepareStorageRootControlDirectory,
+  resolveStorageRoot,
+} from '@maka/storage/root-authority';
+import { readHostRegistration } from '../control/registration.js';
+import type { HostRegistration } from '../protocol/index.js';
+
+const TERMINATION_SETTLE_MS = 2_000;
+
+export interface RegisteredRuntimeHostIdentity {
+  readonly rootPath: string;
+  readonly rootId: string;
+  readonly hostEpoch: string;
+  readonly pid: number;
+}
+
+interface RegisteredRuntimeHostTerminationDependencies {
+  readonly terminateProcess: typeof terminateProcessTree;
+  readonly isProcessAlive: (pid: number) => boolean;
+  readonly settleMs: number;
+}
+
+const defaultDependencies: RegisteredRuntimeHostTerminationDependencies = {
+  terminateProcess: terminateProcessTree,
+  isProcessAlive,
+  settleMs: TERMINATION_SETTLE_MS,
+};
+
+/**
+ * Force-terminates only the exact local ephemeral Host still registered for
+ * the expected State Root. Callers must reserve this for explicit recovery
+ * after graceful retirement fails.
+ */
+export function forceTerminateRegisteredRuntimeHost(
+  identity: RegisteredRuntimeHostIdentity,
+  stillOwnsProcess: () => boolean,
+): Promise<boolean> {
+  return forceTerminateRegisteredRuntimeHostWithDependencies(
+    identity,
+    stillOwnsProcess,
+    defaultDependencies,
+  );
+}
+
+export async function forceTerminateRegisteredRuntimeHostWithDependencies(
+  identity: RegisteredRuntimeHostIdentity,
+  stillOwnsProcess: () => boolean,
+  dependencies: RegisteredRuntimeHostTerminationDependencies,
+): Promise<boolean> {
+  if (!stillOwnsProcess()) return false;
+  const capability = await resolveStorageRoot({ path: identity.rootPath, kind: 'interactive' });
+  if (capability.rootId !== identity.rootId) return false;
+  const { controlDirectory } = await prepareStorageRootControlDirectory(capability);
+  const registered = await readHostRegistration(controlDirectory);
+  if (!registered) return true;
+  if (!matchesIdentity(registered, identity)) return false;
+  if (!dependencies.isProcessAlive(identity.pid)) return true;
+
+  let signalTarget: HostRegistration | undefined = registered;
+  const signaled = await dependencies.terminateProcess({
+    pid: identity.pid,
+    signal: 'SIGKILL',
+    hasExited: () => !dependencies.isProcessAlive(identity.pid),
+    beforeSignal: async () => {
+      // This runs after asynchronous process-tree discovery and immediately
+      // before the OS signal, so neither a successor nor a reused PID can
+      // inherit stale intent.
+      signalTarget = await readHostRegistration(controlDirectory);
+      return matchesIdentity(signalTarget, identity) && stillOwnsProcess();
+    },
+    fallback: () => {
+      try {
+        process.kill(identity.pid, 'SIGKILL');
+        return true;
+      } catch {
+        return false;
+      }
+    },
+  });
+  if (!signalTarget) return true;
+  if (!matchesIdentity(signalTarget, identity)) return false;
+  if (!signaled && dependencies.isProcessAlive(identity.pid)) return false;
+  return waitForExit(identity.pid, dependencies);
+}
+
+function matchesIdentity(
+  registration: HostRegistration | undefined,
+  identity: RegisteredRuntimeHostIdentity,
+): boolean {
+  return (
+    registration?.rootId === identity.rootId &&
+    registration.hostEpoch === identity.hostEpoch &&
+    registration.pid === identity.pid &&
+    registration.lifecycleMode === 'ephemeral'
+  );
+}
+
+async function waitForExit(
+  pid: number,
+  dependencies: RegisteredRuntimeHostTerminationDependencies,
+): Promise<boolean> {
+  const deadline = Date.now() + dependencies.settleMs;
+  while (dependencies.isProcessAlive(pid)) {
+    if (Date.now() >= deadline) return false;
+    await new Promise<void>((resolve) => setTimeout(resolve, 50));
+  }
+  return true;
+}
+
+function isProcessAlive(pid: number): boolean {
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch (error) {
+    return !(
+      error instanceof Error &&
+      'code' in error &&
+      (error as NodeJS.ErrnoException).code === 'ESRCH'
+    );
+  }
+}
diff --git a/packages/runtime/src/process-tree-terminator.ts b/packages/runtime/src/process-tree-terminator.ts
index 95284230fd..99ff479497 100644
--- a/packages/runtime/src/process-tree-terminator.ts
+++ b/packages/runtime/src/process-tree-terminator.ts
@@ -32,7 +32,7 @@ interface ProcessTreeTerminationOptions {
   fallback?: () => boolean | void;
   hasExited?: () => boolean;
   /** Runs after asynchronous topology discovery and before the first OS action. */
-  beforeSignal?: () => boolean;
+  beforeSignal?: () => boolean | Promise<boolean>;
 }
 
 interface PosixProcess {
@@ -69,7 +69,7 @@ export async function terminateProcessTree(
   const { pid, signal, fallback, hasExited, beforeSignal } = options;
   if (hasExited?.()) return false;
   if (process.platform === 'win32') {
-    if (beforeSignal && !beforeSignal()) return false;
+    if (beforeSignal && !(await beforeSignal())) return false;
     if (await killWindowsTree(pid)) return true;
     if (hasExited?.()) return false;
     return invokeFallback(fallback);
@@ -77,7 +77,7 @@ export async function terminateProcessTree(
 
   const processes = await readPosixProcesses();
   if (hasExited?.()) return false;
-  if (beforeSignal && !beforeSignal()) return false;
+  if (beforeSignal && !(await beforeSignal())) return false;
 
   const escapedDescendantSignaled = forceKillEscapedDescendants(pid, processes);
   if (hasExited?.()) return escapedDescendantSignaled;


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