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/01 14:11:46 UTC

(maka) branch fix/session-share-peer-route-liveness updated (53021a696d -> 4c1fc28185)

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

M4n5ter pushed a change to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git


    omit 53021a696d chore: remove PR screenshot asset
    omit faacf3d574 docs(pr): refresh turn approval screenshot
    omit 40b0bf82b1 fix(desktop): compact turn approval prompt
    omit a76484541a fix(peer): retain authenticated guest routes
    omit 6dc06062b6 fix(desktop): preserve available collaboration inboxes
    omit 09553c7bc4 fix(desktop): streamline collaboration invitation transfer
    omit 8bfdd00292 fix(desktop): keep collaboration hook scope observable
    omit f2f124a1e8 fix(peer): recover idle shared-session connections
    omit 78e496e24d fix(desktop): surface pending turn requests
    omit a8aff1f2a6 fix(peer): expose live relay routes to shared sessions
    omit d59d08bafb fix(desktop): issue live peer routes for shared sessions
     add 6668af3f55 ci: allocate a runner only when its inputs changed (#4461)
     new f4e42ea913 fix(desktop): issue live peer routes for shared sessions
     new b372932be0 fix(peer): expose live relay routes to shared sessions
     new eb88b62f19 fix(desktop): surface pending turn requests
     new d74e05a3c7 fix(peer): recover idle shared-session connections
     new ed46a5b012 fix(desktop): keep collaboration hook scope observable
     new 14b12f92c4 fix(desktop): streamline collaboration invitation transfer
     new a35bbb5d3f fix(desktop): preserve available collaboration inboxes
     new c7e5a4b84e fix(peer): retain authenticated guest routes
     new ff574d8b8a fix(desktop): compact turn approval prompt
     new 14cfc9e4a8 docs(pr): refresh turn approval screenshot
     new 4c1fc28185 chore: remove PR screenshot asset

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   (53021a696d)
            \
             N -- N -- N   refs/heads/fix/session-share-peer-route-liveness (4c1fc28185)

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 11 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:
 .asf.yaml                                          |  14 +-
 .github/workflows/ci.yml                           | 183 ++----
 .github/workflows/cli-package-validation.yml       | 211 +++----
 .github/workflows/release-windows-check.yml        |  38 +-
 .github/workflows/windows-recovery.yml             | 120 +++-
 package.json                                       |   2 +-
 scripts/asf-source-workflow-policy.test.mjs        |   2 +-
 scripts/ci-test-plan.mjs                           |  29 +-
 scripts/ci-test-plan.test.mjs                      | 634 +------------------
 ...t-plan.test.mjs => ci-workflow-policy.test.mjs} | 695 +++++++--------------
 scripts/prepare-windows-upgrade-baseline.mjs       |  23 +-
 scripts/prepare-windows-upgrade-baseline.test.mjs  | 136 ++++
 scripts/qualify-released-cli-state-root.test.mjs   |   9 +-
 scripts/release-cli-workflow-policy.test.mjs       |  65 +-
 scripts/update-test-feed-wiring.test.mjs           |  67 ++
 scripts/windows-package-source-closure.mjs         |  23 +-
 scripts/windows-package-source-closure.test.mjs    |  82 ++-
 17 files changed, 962 insertions(+), 1371 deletions(-)
 copy scripts/{ci-test-plan.test.mjs => ci-workflow-policy.test.mjs} (54%)
 create mode 100644 scripts/prepare-windows-upgrade-baseline.test.mjs
 create mode 100644 scripts/update-test-feed-wiring.test.mjs


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


(maka) 01/11: fix(desktop): issue live peer routes for shared sessions

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit f4e42ea913e4f5421dd33c209658f168d0fcc337
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 15:21:34 2026 +0800

    fix(desktop): issue live peer routes for shared sessions
    
    Generated-by: Codex
---
 .../runtime-host-local-remote-access.test.ts       |  94 ++++++++++++-
 .../__tests__/runtime-host-profile-service.test.ts |  45 ++++++-
 apps/desktop/src/main/runtime-host-boot.ts         |   3 +
 apps/desktop/src/main/runtime-host-client.ts       |   5 +
 .../src/main/runtime-host-local-remote-access.ts   |  30 ++++-
 .../src/main/runtime-host-profile-service.ts       |  48 +++++--
 native/runtime-host-peer/src/engine.rs             | 145 ++++++++++-----------
 .../src/__tests__/peer-listener.test.ts            |  28 ++++
 .../runtime-host/src/__tests__/peer-native.test.ts |  16 ++-
 .../runtime-host/src/__tests__/protocol.test.ts    |  30 +++++
 packages/runtime-host/src/client/connection.ts     |   8 +-
 packages/runtime-host/src/client/host-profile.ts   |  11 +-
 packages/runtime-host/src/client/peer-client.ts    |   4 +
 packages/runtime-host/src/peer-mesh/node.ts        |  19 ++-
 packages/runtime-host/src/peer-mesh/owner.ts       |   4 +
 packages/runtime-host/src/protocol/host-status.ts  |  46 +++++++
 packages/runtime-host/src/protocol/index.ts        |   4 +-
 packages/runtime-host/src/protocol/operations.ts   |   1 +
 .../runtime-host/src/server/execution-service.ts   |   3 +
 packages/runtime-host/src/server/host-kernel.ts    |  10 ++
 packages/runtime-host/src/server/listener-set.ts   |  21 +--
 packages/runtime-host/src/server/peer-listener.ts  |   4 +
 22 files changed, 462 insertions(+), 117 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
index 9e502095c6..c6f2e90813 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-local-remote-access.test.ts
@@ -44,17 +44,36 @@ test('enabling remote access hands the same root to one managed service before D
   const handlers = new Map<string, Parameters<Electron.IpcMain['handle']>[1]>();
   let retired = false;
   let resumed = false;
+  const peer = {
+    peerId: '12D3KooWpeer',
+    routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+    coordinationRelays: [],
+  };
+  const livePeer = {
+    ...peer,
+    coordinationRelays: ['/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay'],
+  };
   const manager = {
     async retireOwnedLocalHost() {
       retired = true;
       return { kind: 'retired' as const, resume: () => { resumed = true; } };
     },
+    async waitUntilReady(profileId: string) {
+      assert.equal(profileId, 'local');
+    },
+    current(profileId: string) {
+      assert.equal(profileId, 'local');
+      return {
+        candidate: {
+          client: {
+            async status() {
+              return { peerEndpoint: livePeer };
+            },
+          },
+        },
+      };
+    },
   } as unknown as RuntimeHostDesktopManager;
-  const peer = {
-    peerId: '12D3KooWpeer',
-    routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
-    coordinationRelays: [],
-  };
   const deploymentId = '11111111-1111-4111-8111-111111111111';
   const operator = {
     async runSetup(input: {
@@ -123,7 +142,7 @@ test('enabling remote access hands the same root to one managed service before D
   assert.deepEqual(decodeRuntimeHostOwnerConnectionCode(result.connectionCode), {
     name: decodeRuntimeHostOwnerConnectionCode(result.connectionCode).name,
     rootId: 'a'.repeat(64),
-    transport: { kind: 'libp2p-direct', ...peer },
+    transport: { kind: 'libp2p-direct', ...livePeer },
     credential: 'pending-credential',
   });
   const lifecycle = JSON.parse(
@@ -134,6 +153,69 @@ test('enabling remote access hands the same root to one managed service before D
   assert.equal(lifecycle.deploymentId, deploymentId);
 });
 
+test('shares the running Local Host endpoint instead of its persisted startup routes', async (t) => {
+  const base = await mkdtemp(join(tmpdir(), 'maka-local-live-peer-share-'));
+  t.after(() => rm(base, { recursive: true, force: true }));
+  const clientDataRoot = join(base, 'client');
+  const rootPath = join(clientDataRoot, 'workspaces', 'default');
+  const rootId = 'a'.repeat(64);
+  await mkdir(rootPath, { recursive: true });
+  await writeManagedLifecycle(clientDataRoot, rootPath, rootId);
+  const configuredPeer = {
+    peerId: '12D3KooWpeer',
+    routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+    coordinationRelays: [],
+  };
+  const livePeer = {
+    ...configuredPeer,
+    coordinationRelays: ['/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay'],
+  };
+  const service = createDesktopLocalRuntimeHostRemoteAccess({
+    ipcMain: { handle() {}, removeHandler() {} },
+    clientDataRoot,
+    rootPath,
+    rootId,
+    directPeerAvailable: true,
+    manager: () =>
+      ({
+        current() {
+          return {
+            candidate: {
+              client: {
+                async status() {
+                  return { peerEndpoint: livePeer };
+                },
+              },
+            },
+          };
+        },
+      }) as unknown as RuntimeHostDesktopManager,
+    resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
+    operator: {
+      async runPeer() {
+        return {
+          kind: 'result' as const,
+          action: 'status' as const,
+          status: {
+            state: 'enabled' as const,
+            serviceState: 'running',
+            rootId,
+            ...configuredPeer,
+          },
+        };
+      },
+      async close() {},
+    } as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>,
+  });
+  t.after(() => service.close());
+
+  const target = await service.createCollaborationConnectionTarget();
+  assert.deepEqual(target, {
+    name: target.name,
+    transport: { kind: 'libp2p-direct', ...livePeer },
+  });
+});
+
 test('revokes the one Local sharing authority without changing peer connectivity', async (t) => {
   const base = await mkdtemp(join(tmpdir(), 'maka-local-shared-access-revoke-'));
   t.after(() => rm(base, { recursive: true, force: true }));
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 d99b90a262..12a9886d42 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
@@ -716,12 +716,27 @@ test("keeps a managed Direct route on the SSH profile credential authority", asy
   await managedServices.save(MANAGED_PROFILE, MANAGED_SERVICE);
   const startup = await resolveDesktopRuntimeHostStartup(root, { catalog });
   const activated: ResolvedRuntimeHostProfile[] = [];
+  const livePeer = {
+    peerId: "12D3KooWpeer",
+    routeHints: ["/ip4/192.0.2.9/udp/44002/quic-v1"],
+    coordinationRelays: ["/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay"],
+  };
+  let exposeReadyState = false;
   const service = createDesktopRuntimeHostProfileService({
     clientDataRoot: root,
     startup,
     catalog,
     managedServices,
-    states: () => [connectingLocal()],
+    states: () =>
+      exposeReadyState
+        ? [
+            connectingLocal(),
+            readyWithPeerEndpoint(
+              { profile: MANAGED_PROFILE, credential: "owner-token" },
+              livePeer,
+            ),
+          ]
+        : [connectingLocal()],
     enable: async (target) => {
       activated.push(target);
     },
@@ -755,10 +770,15 @@ test("keeps a managed Direct route on the SSH profile credential authority", asy
     routeHints: ["/ip4/192.0.2.8/udp/44001/quic-v1"],
     coordinationRelays: [],
   });
+  exposeReadyState = true;
   assert.deepEqual(
     await service.resolveCollaborationConnectionTarget(MANAGED_PROFILE),
-    { name: MANAGED_PROFILE.name, transport: direct.profile.transport },
+    {
+      name: MANAGED_PROFILE.name,
+      transport: { kind: "libp2p-direct", ...livePeer },
+    },
   );
+  exposeReadyState = false;
   assert.equal((await catalog.resolve(MANAGED_PROFILE.id)).credential, "owner-token");
 
   const beforeRejectedRemoval = {
@@ -1602,6 +1622,27 @@ function ready(target: ResolvedRuntimeHostProfile): RuntimeHostDesktopTargetStat
   };
 }
 
+function readyWithPeerEndpoint(
+  target: ResolvedRuntimeHostProfile,
+  peerEndpoint: {
+    readonly peerId: string;
+    readonly routeHints: readonly string[];
+    readonly coordinationRelays: readonly string[];
+  },
+): RuntimeHostDesktopTargetState {
+  return {
+    epoch: `epoch-${target.profile.id}`,
+    target,
+    readiness: "ready",
+    candidate: {
+      client: {
+        hostId: target.profile.kind === "remote" ? target.profile.rootId : ROOT_ID,
+        status: async () => ({ peerEndpoint }),
+      },
+    } as never,
+  };
+}
+
 function unavailable(
   target: ResolvedRuntimeHostProfile,
   error: Error,
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 3d352e8370..f4dc7cd64d 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -268,6 +268,9 @@ if (runtimeHostPeerConfiguration) {
       ...runtimeHostPeerConfiguration,
       dataRoot: join(userDataDir, 'peer-mesh'),
       endpointKind: 'client',
+      onBackgroundReconcileError: (error) => {
+        console.error('[runtime-host] Peer Mesh background synchronization failed:', error);
+      },
     });
     runtimeHostPeerClient = runtimeHostPeerOwner.client;
     runtimeHostPeerMesh = runtimeHostPeerOwner.mesh;
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index ca3a5d6b6b..a5a6dbb76e 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -84,6 +84,7 @@ import {
   type MemoryQueryInput,
   type MemoryQueryResult,
   type GoalControlAction,
+  type HostStatusResult,
   type GoalProjection,
   type OperationInput,
   type OperationOutput,
@@ -295,6 +296,10 @@ export class DesktopRuntimeHostClient {
     return this.#connectionClosed || this.#closeTask ? 'unavailable' : 'ready';
   }
 
+  status(): Promise<HostStatusResult> {
+    return this.connection.status();
+  }
+
   finalizeAccessCredential(
     timeoutMs?: number,
   ): Promise<OperationOutput<'access.credential.finalize'>> {
diff --git a/apps/desktop/src/main/runtime-host-local-remote-access.ts b/apps/desktop/src/main/runtime-host-local-remote-access.ts
index 4ade5c5610..3623734975 100644
--- a/apps/desktop/src/main/runtime-host-local-remote-access.ts
+++ b/apps/desktop/src/main/runtime-host-local-remote-access.ts
@@ -339,11 +339,14 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
       };
       const completed = await finishSetup(setup, 'request');
       if (completed.kind === 'active_tasks') return completed;
+      const manager = requireManager(input.manager);
+      await manager.waitUntilReady('local');
+      const livePeer = await readLivePeer(localClient(input.manager), completed.peer);
       return enabledResult(
         encodeRuntimeHostOwnerConnectionCode({
           name: hostName(),
           rootId: completed.managed.rootId,
-          transport: { kind: 'libp2p-direct', ...completed.peer },
+          transport: { kind: 'libp2p-direct', ...livePeer },
           credential: completed.credential,
         }),
       );
@@ -515,9 +518,10 @@ export function createDesktopLocalRuntimeHostRemoteAccess(input: {
       );
       const peer = await readPeer(input.operator, managed);
       if (!peer) throw new Error('Remote access is not enabled on this computer');
+      const livePeer = await readLivePeer(localClient(input.manager), peer);
       return {
         name: hostName(),
-        transport: { kind: 'libp2p-direct' as const, ...peer },
+        transport: { kind: 'libp2p-direct' as const, ...livePeer },
       };
     });
 
@@ -968,6 +972,7 @@ async function issueConnectionCode(
   peer: LocalPeerDescriptor,
   client: DesktopRuntimeHostClient,
 ): Promise<string> {
+  const livePeer = await readLivePeer(client, peer);
   const prepared = await client.request('access.credential.prepare', {
     principalKind: 'remote_owner',
     principalId: LOCAL_REMOTE_ACCESS_PRINCIPAL_ID,
@@ -984,11 +989,30 @@ async function issueConnectionCode(
   return encodeRuntimeHostOwnerConnectionCode({
     name: hostName(),
     rootId,
-    transport: { kind: 'libp2p-direct', ...peer },
+    transport: { kind: 'libp2p-direct', ...livePeer },
     credential,
   });
 }
 
+async function readLivePeer(
+  client: DesktopRuntimeHostClient,
+  configured: LocalPeerDescriptor,
+): Promise<LocalPeerDescriptor> {
+  const endpoint = (await client.status()).peerEndpoint;
+  if (!endpoint) {
+    throw new Error('Runtime Host Direct peer is not available');
+  }
+  if (endpoint.peerId !== configured.peerId) {
+    throw new Error('Runtime Host Direct peer identity changed');
+  }
+  return requireEnabledPeer({
+    state: 'enabled',
+    peerId: endpoint.peerId,
+    routeHints: endpoint.routeHints,
+    coordinationRelays: endpoint.coordinationRelays,
+  });
+}
+
 async function hasSharedAccess(
   operator: DesktopRuntimeHostLocalOperator,
   target: LocalServiceTarget,
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index 33bb1380f7..a310141439 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -920,23 +920,49 @@ export function createDesktopRuntimeHostProfileService(input: {
         if (profile.kind !== 'remote') {
           throw new Error('This Runtime Host does not expose a shareable peer endpoint');
         }
-        if (profile.transport.kind !== 'ssh') {
+        if (profile.transport.kind !== 'ssh' && profile.transport.kind !== 'libp2p-direct') {
           return { name: profile.name, transport: profile.transport };
         }
-        const direct = (await catalog.read()).profiles.find(
-          (candidate) => candidate.id === managedDirectPeerProfileId(profile.id),
-        );
-        if (
-          !direct ||
-          direct.kind !== 'remote' ||
-          direct.rootId !== profile.rootId ||
-          direct.transport.kind !== 'libp2p-direct'
-        ) {
+        let configuredPeerId: string | undefined;
+        if (profile.transport.kind === 'libp2p-direct') {
+          configuredPeerId = profile.transport.peerId;
+        } else {
+          const direct = (await catalog.read()).profiles.find(
+            (candidate) => candidate.id === managedDirectPeerProfileId(profile.id),
+          );
+          if (
+            direct?.kind === 'remote' &&
+            direct.rootId === profile.rootId &&
+            direct.transport.kind === 'libp2p-direct'
+          ) {
+            configuredPeerId = direct.transport.peerId;
+          }
+        }
+        if (!configuredPeerId) {
           throw new Error(
             'Enable Direct peer access for this Runtime Host before sharing its Sessions',
           );
         }
-        return { name: profile.name, transport: direct.transport };
+        const active = input.states().find((state) => state.target.profile.id === profile.id);
+        if (!active || active.readiness !== 'ready') {
+          throw new Error('Connect this Runtime Host before sharing its Sessions');
+        }
+        const endpoint = (await active.candidate.client.status()).peerEndpoint;
+        if (!endpoint) {
+          throw new Error('Runtime Host Direct peer is not available');
+        }
+        if (configuredPeerId !== endpoint.peerId) {
+          throw new Error('Runtime Host Direct peer identity changed');
+        }
+        return {
+          name: profile.name,
+          transport: {
+            kind: 'libp2p-direct' as const,
+            peerId: endpoint.peerId,
+            routeHints: endpoint.routeHints,
+            coordinationRelays: endpoint.coordinationRelays,
+          },
+        };
       });
     },
     resolveManagedAccess(profileId) {
diff --git a/native/runtime-host-peer/src/engine.rs b/native/runtime-host-peer/src/engine.rs
index 357901a468..dd171c035d 100644
--- a/native/runtime-host-peer/src/engine.rs
+++ b/native/runtime-host-peer/src/engine.rs
@@ -644,14 +644,12 @@ async fn run_endpoint_async(
         &mut swarm,
         &mut coordination_relays,
         &HashMap::new(),
-        false,
         Instant::now(),
     );
 
     let startup_deadline = Instant::now() + Duration::from_secs(10);
     let mut address_quiet_deadline = None;
     let mut bound_addresses = HashSet::new();
-    let mut startup_external_candidate_ready = false;
     loop {
         let deadline = address_quiet_deadline
             .unwrap_or(startup_deadline)
@@ -668,13 +666,9 @@ async fn run_endpoint_async(
                     address_quiet_deadline = Some(Instant::now() + LISTENER_ADDRESS_QUIET_PERIOD);
                 }
             }
-            Ok(event) => handle_startup_event(
-                &mut swarm,
-                event,
-                &mut coordination_relays,
-                &mut startup_external_candidate_ready,
-                &mut transit,
-            ),
+            Ok(event) => {
+                handle_startup_event(&mut swarm, event, &mut coordination_relays, &mut transit)
+            }
             Err(_) if pending_listeners.is_empty() => break,
             Err(_) => {
                 return Err(PeerError::new(
@@ -698,7 +692,6 @@ async fn run_endpoint_async(
     let (stream_completed_tx, mut stream_completed_rx) =
         mpsc::channel::<CompletedStream>(MAX_ESTABLISHED_CONNECTIONS as usize);
     let mut direct = DirectConnectState::default();
-    let mut external_candidate_ready = startup_external_candidate_ready;
     let mut deadline_tick = tokio::time::interval(Duration::from_millis(100));
     deadline_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
     let mut discovered_relays = options
@@ -770,7 +763,6 @@ async fn run_endpoint_async(
                         &mut direct,
                         &coordination_relays,
                         &stream_control,
-                        external_candidate_ready,
                         Instant::now(),
                     );
                     start_pending_webrtc_upgrades(
@@ -831,7 +823,6 @@ async fn run_endpoint_async(
                         &mut direct,
                         &coordination_relays,
                         &stream_control,
-                        external_candidate_ready,
                         Instant::now(),
                     );
                     let requests = direct.pending.keys().copied().collect::<Vec<_>>();
@@ -1002,7 +993,6 @@ async fn run_endpoint_async(
                     &mut swarm,
                     &mut coordination_relays,
                     &direct.active,
-                    external_candidate_ready,
                     Instant::now(),
                 );
             }
@@ -1095,7 +1085,6 @@ async fn run_endpoint_async(
                                 &mut direct,
                                 &coordination_relays,
                                 &stream_control,
-                                external_candidate_ready,
                                 Instant::now(),
                             );
                             maybe_open_peer_stream(
@@ -1190,7 +1179,6 @@ async fn run_endpoint_async(
                             &mut direct,
                             &coordination_relays,
                             &stream_control,
-                            external_candidate_ready,
                             Instant::now(),
                         );
                         maybe_open_peer_stream(
@@ -1210,7 +1198,6 @@ async fn run_endpoint_async(
                     event,
                     &mut coordination_relays,
                     &mut direct,
-                    &mut external_candidate_ready,
                     RouteRuntime {
                         active_coordination_relays: &active_coordination_relays,
                         transit: &mut transit,
@@ -1230,7 +1217,6 @@ async fn run_endpoint_async(
                     &mut swarm,
                     &mut coordination_relays,
                     &direct.active,
-                    external_candidate_ready,
                     Instant::now(),
                 );
                 let requests = direct.pending.keys().copied().collect::<Vec<_>>();
@@ -1268,7 +1254,6 @@ async fn run_endpoint_async(
                     &mut swarm,
                     &mut coordination_relays,
                     &direct.active,
-                    external_candidate_ready,
                     now,
                 );
                 retry_connect_routes(
@@ -1276,7 +1261,6 @@ async fn run_endpoint_async(
                     &mut direct,
                     &coordination_relays,
                     &stream_control,
-                    external_candidate_ready,
                     now,
                 );
                 start_pending_webrtc_upgrades(
@@ -1508,13 +1492,7 @@ fn start_connect(
             referenced.insert(relay_peer),
         )?;
     }
-    maintain_coordination_relays(
-        swarm,
-        coordination_relays,
-        active_streams,
-        false,
-        Instant::now(),
-    );
+    maintain_coordination_relays(swarm, coordination_relays, active_streams, Instant::now());
     Ok(StartedConnect {
         direct_routes: direct_targets,
         coordination_relay_peers: relay_peers,
@@ -1793,7 +1771,6 @@ fn handle_swarm_event(
     event: SwarmEvent<BehaviourEvent>,
     coordination_relays: &mut HashMap<PeerId, CoordinationRelay>,
     direct: &mut DirectConnectState,
-    external_candidate_ready: &mut bool,
     route_runtime: RouteRuntime<'_>,
 ) {
     match event {
@@ -2016,13 +1993,7 @@ fn handle_swarm_event(
             if let Some(relay) = coordination_relays.get_mut(&peer_id) {
                 relay.identify_received = true;
             }
-            request_coordination_reservation(
-                swarm,
-                coordination_relays,
-                peer_id,
-                *external_candidate_ready,
-                Instant::now(),
-            );
+            request_coordination_reservation(swarm, coordination_relays, peer_id, Instant::now());
         }
         SwarmEvent::Behaviour(BehaviourEvent::Identify(identify::Event::Sent {
             peer_id, ..
@@ -2030,25 +2001,7 @@ fn handle_swarm_event(
             if let Some(relay) = coordination_relays.get_mut(&peer_id) {
                 relay.identify_sent = true;
             }
-            request_coordination_reservation(
-                swarm,
-                coordination_relays,
-                peer_id,
-                *external_candidate_ready,
-                Instant::now(),
-            );
-        }
-        SwarmEvent::NewExternalAddrCandidate { .. } => {
-            *external_candidate_ready = true;
-            for peer_id in coordination_relays.keys().copied().collect::<Vec<_>>() {
-                request_coordination_reservation(
-                    swarm,
-                    coordination_relays,
-                    peer_id,
-                    true,
-                    Instant::now(),
-                );
-            }
+            request_coordination_reservation(swarm, coordination_relays, peer_id, Instant::now());
         }
         SwarmEvent::ListenerClosed {
             listener_id,
@@ -2093,7 +2046,6 @@ fn handle_startup_event(
     swarm: &mut Swarm<Behaviour>,
     event: SwarmEvent<BehaviourEvent>,
     coordination_relays: &mut HashMap<PeerId, CoordinationRelay>,
-    external_candidate_ready: &mut bool,
     transit: &mut TransitRuntime,
 ) {
     handle_swarm_event(
@@ -2101,7 +2053,6 @@ fn handle_startup_event(
         event,
         coordination_relays,
         &mut DirectConnectState::default(),
-        external_candidate_ready,
         RouteRuntime {
             active_coordination_relays: &Arc::new(RwLock::new(Vec::new())),
             transit,
@@ -2405,7 +2356,7 @@ fn reconcile_transit_reservations(
         }
         relay.direct_connection_addresses.clear();
     }
-    maintain_coordination_relays(swarm, relays, active_streams, true, Instant::now());
+    maintain_coordination_relays(swarm, relays, active_streams, Instant::now());
 }
 
 fn retained_transit_candidates(
@@ -2817,7 +2768,6 @@ fn maintain_coordination_relays(
     swarm: &mut Swarm<Behaviour>,
     relays: &mut HashMap<PeerId, CoordinationRelay>,
     active_streams: &HashMap<ConnectionId, usize>,
-    external_candidate_ready: bool,
     now: Instant,
 ) {
     for peer_id in relays.keys().copied().collect::<Vec<_>>() {
@@ -2870,7 +2820,7 @@ fn maintain_coordination_relays(
             }
             continue;
         }
-        request_coordination_reservation(swarm, relays, peer_id, external_candidate_ready, now);
+        request_coordination_reservation(swarm, relays, peer_id, now);
     }
 }
 
@@ -2878,7 +2828,6 @@ fn request_coordination_reservation(
     swarm: &mut Swarm<Behaviour>,
     relays: &mut HashMap<PeerId, CoordinationRelay>,
     peer_id: PeerId,
-    external_candidate_ready: bool,
     now: Instant,
 ) {
     let Some(relay) = relays.get_mut(&peer_id) else {
@@ -2898,7 +2847,6 @@ fn request_coordination_reservation(
         || relay.reservation_listener.is_some()
         || !identified
         || (transit_provider && !directly_connected)
-        || !external_candidate_ready
         || relay.next_reservation_attempt > now
     {
         return;
@@ -2979,7 +2927,6 @@ fn retry_connect_routes(
     direct: &mut DirectConnectState,
     coordination_relays: &HashMap<PeerId, CoordinationRelay>,
     stream_control: &application_stream::Control,
-    external_candidate_ready: bool,
     now: Instant,
 ) {
     for connect in direct.pending.values_mut() {
@@ -3011,24 +2958,11 @@ fn retry_connect_routes(
             .any(|origin| *origin == DialOrigin::Coordination)
             && (connect.retry_coordination || !stream_control.has_relayed_connection(peer_id))
         {
-            let mut targets = Vec::new();
-            for relay in &connect.coordination_relays {
-                let relay_peer = coordination_relay_peer_id(relay)
-                    .expect("coordination relay was validated before connecting");
-                if !external_candidate_ready
-                    || coordination_relays
-                        .get(&relay_peer)
-                        .is_none_or(|relay| !relay.identify_received || !relay.identify_sent)
-                {
-                    continue;
-                }
-                targets.push(
-                    relay
-                        .clone()
-                        .with(Protocol::P2pCircuit)
-                        .with(Protocol::P2p(peer_id)),
-                );
-            }
+            let targets = coordination_dial_targets(
+                peer_id,
+                &connect.coordination_relays,
+                coordination_relays,
+            );
             if let Some(connection_id) = dial_direct_targets(swarm, peer_id, targets) {
                 connect
                     .dials
@@ -3069,6 +3003,29 @@ fn retry_connect_routes(
     }
 }
 
+fn coordination_dial_targets(
+    peer_id: PeerId,
+    addresses: &[Multiaddr],
+    relays: &HashMap<PeerId, CoordinationRelay>,
+) -> Vec<Multiaddr> {
+    addresses
+        .iter()
+        .filter(|address| {
+            let relay_peer = coordination_relay_peer_id(address)
+                .expect("coordination relay was validated before connecting");
+            relays
+                .get(&relay_peer)
+                .is_some_and(|relay| relay.identify_received && relay.identify_sent)
+        })
+        .map(|address| {
+            address
+                .clone()
+                .with(Protocol::P2pCircuit)
+                .with(Protocol::P2p(peer_id))
+        })
+        .collect()
+}
+
 fn dial_direct_targets(
     swarm: &mut Swarm<Behaviour>,
     peer_id: PeerId,
@@ -3121,6 +3078,36 @@ mod tests {
         );
     }
 
+    #[test]
+    fn coordination_dial_only_requires_an_identified_relay() {
+        let relay_peer_id = PeerId::random();
+        let target_peer_id = PeerId::random();
+        let relay_address: Multiaddr = format!("/ip4/203.0.113.1/tcp/4001/p2p/{relay_peer_id}")
+            .parse()
+            .expect("valid relay address");
+        let relays = HashMap::from([(
+            relay_peer_id,
+            CoordinationRelay {
+                identify_received: true,
+                identify_sent: true,
+                ..CoordinationRelay::default()
+            },
+        )]);
+
+        assert_eq!(
+            coordination_dial_targets(
+                target_peer_id,
+                std::slice::from_ref(&relay_address),
+                &relays,
+            ),
+            vec![
+                relay_address
+                    .with(Protocol::P2pCircuit)
+                    .with(Protocol::P2p(target_peer_id))
+            ],
+        );
+    }
+
     #[test]
     fn completion_at_the_immutable_deadline_cannot_commit() {
         let now = Instant::now();
diff --git a/packages/runtime-host/src/__tests__/peer-listener.test.ts b/packages/runtime-host/src/__tests__/peer-listener.test.ts
index 2a5d900895..e6ec103e18 100644
--- a/packages/runtime-host/src/__tests__/peer-listener.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-listener.test.ts
@@ -21,6 +21,7 @@ import assert from 'node:assert/strict';
 import { setImmediate as waitForImmediate } from 'node:timers/promises';
 import { test } from 'node:test';
 import { createRuntimeHostPeerListener } from '../server/peer-listener.js';
+import { createRuntimeHostListenerSet } from '../server/listener-set.js';
 import type { RuntimeHostPeerClient } from '../client/peer-client.js';
 import type { RuntimeHostPeerNativeStream } from '../transport/peer-native.js';
 
@@ -122,6 +123,33 @@ test('bounds active application streams from one authenticated peer', async () =
   await listener.cleanup();
 });
 
+test('projects newly accepted coordination relays from the running peer endpoint', async () => {
+  let coordinationRelays: readonly string[] = [];
+  const peer = {
+    ...peerWith([]),
+    identity: () => ({
+      peerId: 'peer',
+      listenAddresses: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+      coordinationRelays,
+    }),
+  };
+  const listener = createRuntimeHostPeerListener(peer, {} as never, () => {});
+  const listeners = createRuntimeHostListenerSet(
+    {
+      kind: 'local_ipc',
+      endpoint: 'local',
+      closeAdmission: async () => undefined,
+      cleanup: async () => undefined,
+    },
+    [listener],
+  );
+
+  assert.deepEqual(listeners.peerListeners[0]?.coordinationRelays, []);
+  coordinationRelays = ['/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay'];
+  assert.deepEqual(listeners.peerListeners[0]?.coordinationRelays, coordinationRelays);
+  await listeners.cleanup();
+});
+
 function peerWith(streams: RuntimeHostPeerNativeStream[]): RuntimeHostPeerClient {
   return {
     identity: () => ({ peerId: 'peer', listenAddresses: [], coordinationRelays: [] }),
diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts
index fc12167100..ce5627cf97 100644
--- a/packages/runtime-host/src/__tests__/peer-native.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-native.test.ts
@@ -72,7 +72,7 @@ module.exports = {
       connect: ({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }) => {
         stats.requests.push({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds });
         if (peerId === 'unreachable') return Promise.reject(Object.assign(new Error('transit_unavailable: no approved route'), { code: 'GenericFailure' }));
-        if (peerId === 'ready' || peerId === 'fallback') return Promise.resolve(stream);
+        if (peerId === 'ready' || peerId === 'fallback' || peerId === 'self-contained') return Promise.resolve(stream);
         return new Promise((resolve, reject) => pending.set(requestId, { resolve, reject }));
       },
       connectMeshControl: ({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }) => {
@@ -100,11 +100,13 @@ module.exports = {
 `,
     );
     let routesPrepared = false;
+    const preparedPeerIds: string[] = [];
     const client = createRuntimeHostPeerClient({
       nativePath,
       keyPath: join(directory, 'peer.key'),
       routeResolver: {
         prepareRoutes: async (peerId) => {
+          preparedPeerIds.push(peerId);
           routesPrepared = true;
           if (peerId === 'fallback') throw new Error('Mesh refresh failed');
         },
@@ -151,6 +153,11 @@ module.exports = {
     await assert.rejects(client.connect(peerConnectInput('unreachable')), (failure: unknown) => {
       return failure instanceof RuntimeHostPeerError && failure.code === 'transit_unavailable';
     });
+    await client.connect({
+      ...peerConnectInput('self-contained'),
+      coordinationRelays: ['/memory/explicit-relay'],
+    });
+    assert.equal(preparedPeerIds.includes('self-contained'), false);
     assert.deepEqual(native.default.stats, {
       starts: 1,
       closes: 0,
@@ -197,6 +204,13 @@ module.exports = {
           coordinationRelays: ['/memory/relay'],
           transitRelayPeerIds: ['transit-peer'],
         },
+        {
+          requestId: 7,
+          peerId: 'self-contained',
+          routeHints: ['/memory/discovered', '/memory/1'],
+          coordinationRelays: ['/memory/relay', '/memory/explicit-relay'],
+          transitRelayPeerIds: ['transit-peer'],
+        },
       ],
       cancellations: [1, 1],
     });
diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts
index db9f2d70f6..3bbe698872 100644
--- a/packages/runtime-host/src/__tests__/protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/protocol.test.ts
@@ -1924,6 +1924,36 @@ describe('Runtime Host bootstrap protocol', () => {
     );
   });
 
+  test('publishes a bounded live Direct peer endpoint through Host status', () => {
+    const status = {
+      hostEpoch: 'epoch-1',
+      compositionId: 'maka.interactive',
+      compositionRevision: '1',
+      state: 'ready',
+      connections: 1,
+      activeOperations: 0,
+      activeResidencies: 0,
+      peerEndpoint: {
+        peerId: '12D3KooWhost',
+        routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+        coordinationRelays: ['/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay'],
+      },
+    };
+    assert.deepEqual(HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput(status), status);
+    assert.throws(() =>
+      HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput({
+        ...status,
+        peerEndpoint: {
+          ...status.peerEndpoint,
+          coordinationRelays: [
+            status.peerEndpoint.coordinationRelays[0],
+            status.peerEndpoint.coordinationRelays[0],
+          ],
+        },
+      }),
+    );
+  });
+
   test('keeps Runtime Host logs within the diagnostics operation contract', () => {
     for (let index = 0; index < 257; index += 1) {
       runtimeHostLogBuffer.append('info', `entry ${index}`);
diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts
index 109cb47b99..4ec10ddae1 100644
--- a/packages/runtime-host/src/client/connection.ts
+++ b/packages/runtime-host/src/client/connection.ts
@@ -196,6 +196,7 @@ export type ConnectRemoteRuntimeHostResult =
         | 'unreachable'
         | 'connect_failed'
         | 'handshake_failed'
+        | 'handshake_timed_out'
         | 'root_mismatch'
         | 'composition_mismatch';
     };
@@ -1035,11 +1036,13 @@ export async function connectRuntimeHostMessageTransport(
 ): Promise<ConnectRemoteRuntimeHostResult> {
   let resourceTransferred = false;
   let timer: ReturnType<typeof setTimeout> | undefined;
+  let handshakeTimedOut = false;
   try {
     const normalized = normalizeConnectRuntimeHostInput(input);
     const compositionId = requireHostCompositionId(input.compositionId);
     const expectedRootId = requireHostRootId(input.expectedRootId);
     timer = setTimeout(() => {
+      handshakeTimedOut = true;
       input.transport.abort(new Error('Timed out handshaking with Runtime Host'));
     }, normalized.handshakeTimeoutMs);
     const result = await exchangeRuntimeHostHandshake({
@@ -1067,7 +1070,10 @@ export async function connectRuntimeHostMessageTransport(
     if (error instanceof RuntimeHostCompositionMismatchError) {
       return { kind: 'unavailable', reason: 'composition_mismatch' };
     }
-    return { kind: 'unavailable', reason: 'handshake_failed' };
+    return {
+      kind: 'unavailable',
+      reason: handshakeTimedOut ? 'handshake_timed_out' : 'handshake_failed',
+    };
   } finally {
     if (timer) clearTimeout(timer);
     if (!resourceTransferred) await input.connectionResource?.close().catch(() => undefined);
diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts
index ea0a72ac9c..feab8c1fd6 100644
--- a/packages/runtime-host/src/client/host-profile.ts
+++ b/packages/runtime-host/src/client/host-profile.ts
@@ -71,6 +71,7 @@ const PROFILE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
 const PEER_ID_MAX_BYTES = 160;
 const PEER_ADDRESS_MAX_BYTES = 2 * 1024;
 const PEER_ROUTE_MAX = 16;
+const DEFAULT_PEER_HANDSHAKE_TIMEOUT_MS = 5_000;
 const PROFILE_CREDENTIAL_RECORD_PREFIX = 'maka-runtime-host-profile-credential-v1:';
 const PROFILE_INCARNATION_ID_MAX_BYTES = 128;
 export const RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES = 8 * 1024;
@@ -588,6 +589,7 @@ export async function connectPeerRuntimeHost(input: {
   readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void;
 }): Promise<RuntimeHostConnection> {
   input.signal?.throwIfAborted();
+  const handshakeTimeoutMs = input.handshakeTimeoutMs ?? DEFAULT_PEER_HANDSHAKE_TIMEOUT_MS;
   const stream = await input.peerClient.connect(
     {
       peerId: input.transport.peerId,
@@ -608,7 +610,7 @@ export async function connectPeerRuntimeHost(input: {
     await writeRuntimeHostPeerAuthentication(stream, input.credential);
     const authentication = await readRuntimeHostPeerAuthenticationResult(
       stream,
-      input.handshakeTimeoutMs,
+      handshakeTimeoutMs,
     );
     if (!authentication.accepted) {
       throw new RuntimeHostProfileConnectionError(
@@ -628,9 +630,7 @@ export async function connectPeerRuntimeHost(input: {
         max: RUNTIME_HOST_PROTOCOL_VERSION,
       },
       clientInstanceId: input.clientInstanceId,
-      ...(input.handshakeTimeoutMs === undefined
-        ? {}
-        : { handshakeTimeoutMs: input.handshakeTimeoutMs }),
+      handshakeTimeoutMs,
       ...(stream.path ? { peerPath: stream.path } : {}),
     });
     input.signal?.throwIfAborted();
@@ -700,6 +700,9 @@ export function remoteRuntimeHostUnavailableError(
     case 'unreachable':
       message = `${subject} could not reach its endpoint`;
       break;
+    case 'handshake_timed_out':
+      message = `${subject} timed out while establishing its protocol session`;
+      break;
     default:
       message = `${subject} is unavailable (${reason})`;
   }
diff --git a/packages/runtime-host/src/client/peer-client.ts b/packages/runtime-host/src/client/peer-client.ts
index 543efdb2f0..e8f639141f 100644
--- a/packages/runtime-host/src/client/peer-client.ts
+++ b/packages/runtime-host/src/client/peer-client.ts
@@ -226,6 +226,10 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     signal: AbortSignal | undefined,
   ): Promise<void> {
     if (!this.#routeResolver?.prepareRoutes) return;
+    // A freshly issued target with both direct hints and coordination relays is
+    // already self-contained. Cached Mesh state can still enrich the actual
+    // dial below, but a stale control plane must not delay this explicit path.
+    if (input.routeHints.length > 0 && (input.coordinationRelays?.length ?? 0) > 0) return;
     const deadline = AbortSignal.timeout(Math.min(10_000, input.directDeadlineMs));
     const operationSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
     try {
diff --git a/packages/runtime-host/src/peer-mesh/node.ts b/packages/runtime-host/src/peer-mesh/node.ts
index d49068723e..2e16e5469c 100644
--- a/packages/runtime-host/src/peer-mesh/node.ts
+++ b/packages/runtime-host/src/peer-mesh/node.ts
@@ -224,6 +224,7 @@ export async function openPeerMeshNode(input: {
   readonly peer: PeerMeshTransport;
   readonly endpointKind?: 'client' | 'host';
   readonly now?: () => number;
+  readonly onBackgroundReconcileError?: (error: unknown) => void;
 }): Promise<PeerMeshNode> {
   const store = await openPeerMeshStateStore(input.dataRoot, input.peer.identity().peerId);
   const node = new PeerMeshNodeImpl({ ...input, store });
@@ -241,6 +242,7 @@ class PeerMeshNodeImpl implements PeerMeshNode {
   readonly #peer: PeerMeshTransport;
   readonly #endpointKind: 'client' | 'host' | undefined;
   readonly #now: () => number;
+  readonly #onBackgroundReconcileError: ((error: unknown) => void) | undefined;
   readonly #activeControlStreams = new Set<RuntimeHostPeerNativeStream>();
   readonly #lifetime = new AbortController();
   #admissionTail = Promise.resolve();
@@ -256,11 +258,13 @@ class PeerMeshNodeImpl implements PeerMeshNode {
     readonly peer: PeerMeshTransport;
     readonly endpointKind?: 'client' | 'host';
     readonly now?: () => number;
+    readonly onBackgroundReconcileError?: (error: unknown) => void;
   }) {
     this.#store = input.store;
     this.#peer = input.peer;
     this.#endpointKind = input.endpointKind;
     this.#now = input.now ?? Date.now;
+    this.#onBackgroundReconcileError = input.onBackgroundReconcileError;
   }
 
   async initialize(): Promise<void> {
@@ -910,8 +914,21 @@ class PeerMeshNodeImpl implements PeerMeshNode {
   }
 
   async #runReconciliation(signal: AbortSignal): Promise<void> {
+    let failureReported = false;
     while (!signal.aborted) {
-      await this.reconcile(signal).catch(() => undefined);
+      try {
+        await this.reconcile(signal);
+        failureReported = false;
+      } catch (error) {
+        if (!signal.aborted && !failureReported) {
+          failureReported = true;
+          try {
+            this.#onBackgroundReconcileError?.(error);
+          } catch {
+            // Diagnostics cannot control Peer Mesh reconciliation.
+          }
+        }
+      }
       await delay(RECONCILE_INTERVAL_MS, undefined, { signal }).catch(() => undefined);
     }
   }
diff --git a/packages/runtime-host/src/peer-mesh/owner.ts b/packages/runtime-host/src/peer-mesh/owner.ts
index 5bde0a8d10..035ab2a46b 100644
--- a/packages/runtime-host/src/peer-mesh/owner.ts
+++ b/packages/runtime-host/src/peer-mesh/owner.ts
@@ -44,6 +44,7 @@ export async function openRuntimeHostPeerMeshOwner(input: {
   readonly coordinationRelays?: readonly string[];
   readonly automaticRelayDiscovery?: boolean;
   readonly webRtcStunUrls?: readonly string[];
+  readonly onBackgroundReconcileError?: (error: unknown) => void;
 }): Promise<RuntimeHostPeerMeshOwner> {
   let mesh: PeerMeshNode | undefined;
   let resolverMesh: PeerMeshNode | undefined;
@@ -77,6 +78,9 @@ export async function openRuntimeHostPeerMeshOwner(input: {
       dataRoot: join(input.dataRoot, client.identity().peerId),
       peer: client,
       endpointKind: input.endpointKind,
+      ...(input.onBackgroundReconcileError
+        ? { onBackgroundReconcileError: input.onBackgroundReconcileError }
+        : {}),
     });
     resolverMesh = mesh;
   } catch (error) {
diff --git a/packages/runtime-host/src/protocol/host-status.ts b/packages/runtime-host/src/protocol/host-status.ts
index 4aac3a9603..a28ef97438 100644
--- a/packages/runtime-host/src/protocol/host-status.ts
+++ b/packages/runtime-host/src/protocol/host-status.ts
@@ -51,6 +51,9 @@ export type HostUpgradePrepareResult =
 export const HOST_DIAGNOSTICS_RESULT_MAX_BYTES = 72 * 1024;
 export const HOST_DIAGNOSTIC_LOG_MAX_ENTRIES = 256;
 export const HOST_DIAGNOSTIC_LOG_MAX_ENTRY_BYTES = 10 * 1024;
+const HOST_PEER_ID_MAX_BYTES = 160;
+const HOST_PEER_ADDRESS_MAX_BYTES = 2 * 1024;
+const HOST_PEER_ROUTE_MAX = 16;
 
 export interface HostStatusResult {
   hostEpoch: string;
@@ -60,6 +63,13 @@ export interface HostStatusResult {
   connections: number;
   activeOperations: number;
   activeResidencies: number;
+  peerEndpoint?: HostPeerEndpoint;
+}
+
+export interface HostPeerEndpoint {
+  readonly peerId: string;
+  readonly routeHints: readonly string[];
+  readonly coordinationRelays: readonly string[];
 }
 
 export interface HostDiagnosticsResult extends HostStatusResult {
@@ -100,12 +110,26 @@ export const HOST_BOOTSTRAP_OPERATION_SPECS = {
   }),
 } as const;
 
+function decodePeerAddresses(value: unknown, label: string): readonly string[] {
+  if (!Array.isArray(value) || value.length > HOST_PEER_ROUTE_MAX) {
+    throw invalidProtocolFrame(`Invalid ${label}`);
+  }
+  const addresses = value.map((address) =>
+    requireString(address, label, HOST_PEER_ADDRESS_MAX_BYTES),
+  );
+  if (new Set(addresses).size !== addresses.length) {
+    throw invalidProtocolFrame(`Duplicate ${label}`);
+  }
+  return Object.freeze(addresses);
+}
+
 function decodeEmptyHostInput(value: unknown, label: string): HostStatusInput {
   requireExactRecord(value, label, []);
   return {};
 }
 
 function decodeHostStatusResult(value: unknown): HostStatusResult {
+  const valueRecord = requireRecord(value, 'host.status result');
   const record = requireExactRecord(value, 'host.status result', [
     'hostEpoch',
     'compositionId',
@@ -114,6 +138,7 @@ function decodeHostStatusResult(value: unknown): HostStatusResult {
     'connections',
     'activeOperations',
     'activeResidencies',
+    ...(valueRecord.peerEndpoint === undefined ? [] : ['peerEndpoint']),
   ]);
   return decodeHostStatusFields(record);
 }
@@ -124,6 +149,7 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult {
     'host.diagnostics.query result',
     HOST_DIAGNOSTICS_RESULT_MAX_BYTES,
   );
+  const valueRecord = requireRecord(value, 'host.diagnostics.query result');
   const record = requireExactRecord(value, 'host.diagnostics.query result', [
     'hostEpoch',
     'compositionId',
@@ -132,6 +158,7 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult {
     'connections',
     'activeOperations',
     'activeResidencies',
+    ...(valueRecord.peerEndpoint === undefined ? [] : ['peerEndpoint']),
     'compositionModules',
     'residencies',
     'protocolVersion',
@@ -262,6 +289,25 @@ function decodeHostStatusFields(record: Record<string, unknown>): HostStatusResu
     connections: requireCount(record.connections, 'connections'),
     activeOperations: requireCount(record.activeOperations, 'activeOperations'),
     activeResidencies: requireCount(record.activeResidencies, 'activeResidencies'),
+    ...(record.peerEndpoint === undefined
+      ? {}
+      : { peerEndpoint: decodeHostPeerEndpoint(record.peerEndpoint) }),
+  };
+}
+
+function decodeHostPeerEndpoint(value: unknown): HostPeerEndpoint {
+  const record = requireExactRecord(value, 'Runtime Host peer endpoint', [
+    'peerId',
+    'routeHints',
+    'coordinationRelays',
+  ]);
+  return {
+    peerId: requireString(record.peerId, 'Runtime Host peer id', HOST_PEER_ID_MAX_BYTES),
+    routeHints: decodePeerAddresses(record.routeHints, 'Runtime Host peer route hints'),
+    coordinationRelays: decodePeerAddresses(
+      record.coordinationRelays,
+      'Runtime Host peer coordination relays',
+    ),
   };
 }
 
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 8aff63a16a..8ad38a70d4 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 87 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 88 as const;
+// 88: Host status publishes the live Direct peer endpoint so newly issued
+// connection invitations do not preserve stale startup routes.
 // 87: The connection catalog projects each model as the Host resolved it —
 // a `catalog_entry` item per model, counted by the connection header. Clients
 // render those entries instead of merging the stored row against their own
diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts
index 1f6f900805..f87902936e 100644
--- a/packages/runtime-host/src/protocol/operations.ts
+++ b/packages/runtime-host/src/protocol/operations.ts
@@ -71,6 +71,7 @@ export type {
   HostDiagnosticsResult,
   HostActivitySnapshot,
   HostLifecycleState,
+  HostPeerEndpoint,
   HostStatusInput,
   HostStatusResult,
   HostUpgradePrepareInput,
diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts
index 1ae258c3ff..baf69cdbc4 100644
--- a/packages/runtime-host/src/server/execution-service.ts
+++ b/packages/runtime-host/src/server/execution-service.ts
@@ -99,6 +99,9 @@ export async function startExecutionRuntimeHostService(
           ...options.peer,
           dataRoot: options.peer.meshDataRoot,
           endpointKind: 'host',
+          onBackgroundReconcileError: (error) => {
+            console.error('[runtime-host] Peer Mesh background synchronization failed:', error);
+          },
         });
       } catch (error) {
         console.error(
diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts
index e86705ce28..491e663212 100644
--- a/packages/runtime-host/src/server/host-kernel.ts
+++ b/packages/runtime-host/src/server/host-kernel.ts
@@ -825,6 +825,7 @@ export class RuntimeHostKernel {
   }
 
   #statusSnapshot(): HostStatusResult {
+    const peer = this.peerListeners[0];
     return {
       hostEpoch: this.hostEpoch,
       compositionId: this.compositionDescriptor.id,
@@ -833,6 +834,15 @@ export class RuntimeHostKernel {
       connections: this.#acceptedTransports.size,
       activeOperations: this.#activeOperations,
       activeResidencies: this.#residencies.activeCount,
+      ...(peer
+        ? {
+            peerEndpoint: {
+              peerId: peer.peerId,
+              routeHints: peer.listenAddresses,
+              coordinationRelays: peer.coordinationRelays,
+            },
+          }
+        : {}),
     };
   }
 
diff --git a/packages/runtime-host/src/server/listener-set.ts b/packages/runtime-host/src/server/listener-set.ts
index c26508e581..cdc48047e5 100644
--- a/packages/runtime-host/src/server/listener-set.ts
+++ b/packages/runtime-host/src/server/listener-set.ts
@@ -46,11 +46,13 @@ export interface RuntimeHostPeerListener extends RuntimeHostListener {
   readonly kind: 'libp2p_direct';
   readonly peerId: string;
   readonly listenAddresses: readonly string[];
+  readonly coordinationRelays: readonly string[];
 }
 
 export interface RuntimeHostPeerListenerDescriptor {
   readonly peerId: string;
   readonly listenAddresses: readonly string[];
+  readonly coordinationRelays: readonly string[];
 }
 
 export type RuntimeHostListenerKind = 'local_ipc' | 'websocket' | 'libp2p_direct';
@@ -135,14 +137,17 @@ export function createRuntimeHostListenerSet(
         .filter((listener) => listener.kind === 'websocket')
         .map((listener) => listener.endpoint),
     ),
-    peerListeners: Object.freeze(
-      additional.filter(isRuntimeHostPeerListener).map((listener) =>
-        Object.freeze({
-          peerId: listener.peerId,
-          listenAddresses: Object.freeze([...listener.listenAddresses]),
-        }),
-      ),
-    ),
+    get peerListeners() {
+      return Object.freeze(
+        additional.filter(isRuntimeHostPeerListener).map((listener) =>
+          Object.freeze({
+            peerId: listener.peerId,
+            listenAddresses: Object.freeze([...listener.listenAddresses]),
+            coordinationRelays: Object.freeze([...listener.coordinationRelays]),
+          }),
+        ),
+      );
+    },
     closeAdmission: () => settleListeners(listeners, (listener) => listener.closeAdmission()),
     cleanup: () => settleListeners([...listeners].reverse(), (listener) => listener.cleanup()),
   };
diff --git a/packages/runtime-host/src/server/peer-listener.ts b/packages/runtime-host/src/server/peer-listener.ts
index 750f40bcce..3907669793 100644
--- a/packages/runtime-host/src/server/peer-listener.ts
+++ b/packages/runtime-host/src/server/peer-listener.ts
@@ -120,6 +120,10 @@ class RuntimeHostPeerListener implements RuntimeHostPeerListenerContract {
       .catch(captureFailure);
   }
 
+  get coordinationRelays(): readonly string[] {
+    return this.#client.identity().coordinationRelays;
+  }
+
   closeAdmission(): Promise<void> {
     this.#closeAdmissionTask ??= (async () => {
       this.#admitting = false;


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


(maka) 05/11: fix(desktop): keep collaboration hook scope observable

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit ed46a5b012afc5fa3a3fa66a39daf73009d8d492
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 18:57:49 2026 +0800

    fix(desktop): keep collaboration hook scope observable
---
 apps/desktop/src/renderer/app-shell.tsx | 19 +++++++++++--------
 1 file changed, 11 insertions(+), 8 deletions(-)

diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 1610fbdcbc..100f14385c 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -109,7 +109,12 @@ import {
 import { useNewTaskChoice } from './use-new-task-choice';
 import { SessionCollaborationDialog } from './session-collaboration-dialog';
 import { SessionTurnRequestComposer } from './session-turn-request-composer.js';
-import * as SessionCollaboration from './features/session-collaboration';
+import {
+  SessionTurnRequestApprovalForSession,
+  SessionTurnRequestBadge,
+  SessionTurnRequestInboxProvider,
+  useSessionCollaborationDialog,
+} from './features/session-collaboration';
 import { getSessionCollaborationCopy } from './locales/session-collaboration-copy';
 import { NEW_TASK_PENDING_KEY } from './pending-items';
 import { parseDesktopSlashCommand } from './desktop-slash-command';
@@ -331,7 +336,7 @@ function AppShellContent({
 }) {
   const toastApi = useToast();
   const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus | null>(null);
-  const sharedSessionDialog = SessionCollaboration.useSessionCollaborationDialog();
+  const sharedSessionDialog = useSessionCollaborationDialog();
   const updateInstallInFlightRef = useRef(false);
   const notifiedInstallErrorRef = useRef<string | null>(null);
   const previousInterruptionShownRef = useRef(false);
@@ -2726,7 +2731,7 @@ function AppShellContent({
       reportError={showSessionError}
     >
     <ComposerMentionsProvider {...composerMentionsSurface}>
-    <SessionCollaboration.SessionTurnRequestInboxProvider
+    <SessionTurnRequestInboxProvider
       sessions={sessions}
       toast={toastApi}
       onOpenSession={openSession}
@@ -2860,7 +2865,7 @@ function AppShellContent({
             projects={localProjects}
             streamingSessionIds={streamingSessionIds}
             staleSessionIds={staleSessionIds}
-            SessionBadge={SessionCollaboration.SessionTurnRequestBadge}
+            SessionBadge={SessionTurnRequestBadge}
             ports={sessionNavigationPorts}
             commandsRef={sessionNavigationCommandsRef}
             onExitWorkHub={exitWorkHub}
@@ -2930,9 +2935,7 @@ function AppShellContent({
                 composer={
                   <>
                     {!sharedSessionActive && activeId ? (
-                      <SessionCollaboration.SessionTurnRequestApprovalForSession
-                        sessionId={activeId}
-                      />
+                      <SessionTurnRequestApprovalForSession sessionId={activeId} />
                     ) : null}
                     {!sharedSessionActive && navSelection.section === 'sessions' &&
                     activeId &&
@@ -3333,7 +3336,7 @@ function AppShellContent({
         onSelectedRuntimeHostProfileIdChange={setSettingsProfileId}
       />
     </div>
-    </SessionCollaboration.SessionTurnRequestInboxProvider>
+    </SessionTurnRequestInboxProvider>
     </ComposerMentionsProvider>
     </Goals.GoalProvider>
   );


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


(maka) 03/11: fix(desktop): surface pending turn requests

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit eb88b62f19af0b5cff2f4cf5aa06e74497b9cf1b
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 17:46:38 2026 +0800

    fix(desktop): surface pending turn requests
---
 apps/desktop/renderer-architecture.json            |  18 +--
 .../runtime-host-collaboration-ipc-main.test.ts    |  11 ++
 .../session-turn-request-inbox-model.test.ts       |  64 ++++++++++
 apps/desktop/src/main/runtime-host-client.ts       |   7 +-
 .../main/runtime-host-collaboration-ipc-main.ts    |   5 +
 apps/desktop/src/preload/bridge-contract.d.ts      |   2 +
 apps/desktop/src/preload/preload.ts                |  27 ++++
 apps/desktop/src/renderer/app-shell.tsx            |  49 ++++---
 .../use-session-collaboration-dialog.ts            |  12 ++
 .../controller/use-turn-request-inbox.ts           | 142 +++++++++++++++++++++
 .../features/session-collaboration/index.ts        |  10 ++
 .../model/turn-request-inbox.ts                    |  62 +++++++++
 .../features/session-collaboration/ports.ts        |  10 ++
 .../session-collaboration/{index.ts => testing.ts} |   9 +-
 .../turn-request-inbox-context.tsx                 |  72 +++++++++++
 .../ui/session-turn-request-approval.tsx           |  88 +++++++++++++
 .../ui/session-turn-request-badge.tsx}             |  26 ++--
 .../ui/session-navigation-provider.tsx             |  42 ++++--
 .../renderer/locales/session-collaboration-copy.ts |  20 ++-
 .../create-session-collaboration-services.ts       |   3 +
 .../src/renderer/session-collaboration-dialog.tsx  |  45 +++++--
 apps/desktop/src/renderer/styles/shell-layout.css  |  19 +++
 apps/desktop/src/renderer/styles/sidebar.css       |   5 +
 .../session-collaboration-authority.test.ts        |  53 +++++++-
 packages/runtime-host/src/protocol/index.ts        |   3 +-
 .../src/protocol/session-collaboration.ts          |   9 +-
 .../runtime-host/src/server/access-authority.ts    |   3 +-
 packages/ui/src/session-history-list.tsx           |   5 +
 packages/ui/src/session-rail-context.tsx           |   1 +
 29 files changed, 724 insertions(+), 98 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index db13adebe6..c73082f51d 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -236,7 +236,6 @@
     "src/renderer/use-new-task-choice.ts",
     "src/renderer/use-onboarding-snapshot.ts",
     "src/renderer/use-project-context.ts",
-    "src/renderer/use-session-collaboration-dialog.ts",
     "src/renderer/use-session-setting-intent.ts",
     "src/renderer/use-settings-modal.ts",
     "src/renderer/use-shell-appearance.ts",
@@ -987,6 +986,7 @@
           "./error-boundary": 1,
           "./features/goals": 1,
           "./features/module-hub": 1,
+          "./features/session-collaboration": 1,
           "./features/session-navigation": 1,
           "./features/task-entry": 1,
           "./features/workbar": 1,
@@ -1022,7 +1022,6 @@
           "./use-new-task-choice": 1,
           "./use-onboarding-snapshot": 1,
           "./use-project-context": 1,
-          "./use-session-collaboration-dialog": 1,
           "./use-session-setting-intent": 1,
           "./use-settings-modal": 1,
           "./use-shell-appearance": 1,
@@ -1060,7 +1059,7 @@
           "react": 1
         },
         "importSpecifiers": 186,
-        "nonTriviaTokens": 15825
+        "nonTriviaTokens": 15795
       },
       "src/renderer/use-app-shell-composer-quotes.ts": {
         "importDeclarations": 3,
@@ -4975,19 +4974,6 @@
           "react": 1
         }
       },
-      "src/renderer/use-session-collaboration-dialog.ts": {
-        "bridgePaths": {},
-        "environmentCapabilities": {},
-        "hookCalls": {
-          "useState": 1
-        },
-        "lifecycleMethods": {},
-        "unresolvedDependencies": 0,
-        "actionFactories": [],
-        "dependencyPaths": {
-          "react": 1
-        }
-      },
       "src/renderer/use-session-setting-intent.ts": {
         "bridgePaths": {},
         "environmentCapabilities": {},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
index 3596921c8f..2ab160b0ee 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
@@ -56,6 +56,10 @@ test('requires plaintext confirmation and reports the issued invitation routes',
     async queryCollaborationAccess() {
       return { principals: [], grants: [] };
     },
+    async queryCollaborationTurnRequests(sessionId?: string) {
+      assert.equal(sessionId, undefined);
+      return { canRequestTurns: false, requests: [] };
+    },
     async revokeCollaborationPrincipal() {
       return { revoked: false };
     },
@@ -99,6 +103,13 @@ test('requires plaintext confirmation and reports the issued invitation routes',
   assert.equal(decodeCollaborationInvitationCode(bundle.invitationCode).rootId, ROOT_ID);
   assert.equal(bundle.target.transport.kind, 'plaintext');
 
+  const inbox = handlers.get('session-collaboration:turn-request:inbox');
+  assert.ok(inbox);
+  assert.deepEqual(await inbox({} as Parameters<IpcHandler>[0]), {
+    canRequestTurns: false,
+    requests: [],
+  });
+
   const peerHandlers = new Map<string, IpcHandler>();
   registerRuntimeHostCollaborationIpc(
     client as unknown as Parameters<typeof registerRuntimeHostCollaborationIpc>[0],
diff --git a/apps/desktop/src/main/__tests__/session-turn-request-inbox-model.test.ts b/apps/desktop/src/main/__tests__/session-turn-request-inbox-model.test.ts
new file mode 100644
index 0000000000..eba12f0ea8
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/session-turn-request-inbox-model.test.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import {
+  groupPendingTurnRequests,
+  samePendingTurnRequests,
+  turnRequestPreview,
+  unseenTurnRequests,
+} from '../../renderer/features/session-collaboration/testing.js';
+
+test('Turn-request inbox keeps only actionable requests and detects new arrivals', () => {
+  const first = request('request-1', 'session-1', '  Review\nthis change  ');
+  const second = request('request-2', 'session-1', 'Continue');
+  const decided: SessionTurnAccessRequest = {
+    ...request('request-3', 'session-2', 'Already handled'),
+    state: {
+      kind: 'rejected',
+      decidedAt: '2026-09-01T00:00:03.000Z',
+      decidedBy: 'local_owner',
+    },
+  };
+
+  assert.deepEqual([...groupPendingTurnRequests([first, decided, second])], [
+    ['session-1', [first, second]],
+  ]);
+  assert.deepEqual(unseenTurnRequests([first, second], new Set(['request-1'])), [second]);
+  assert.equal(samePendingTurnRequests([first, second], [first, second]), true);
+  assert.equal(samePendingTurnRequests([first], [second]), false);
+  assert.equal(turnRequestPreview(first.intent.content.text), 'Review this change');
+});
+
+function request(
+  requestId: string,
+  sessionId: string,
+  text: string,
+): SessionTurnAccessRequest {
+  return {
+    requestId,
+    principalId: `session_guest:${requestId}`,
+    grantId: `grant-${requestId}`,
+    intent: { sessionId, turnId: `turn-${requestId}`, content: { text } },
+    createdAt: '2026-09-01T00:00:00.000Z',
+    state: { kind: 'pending' },
+  };
+}
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index a5a6dbb76e..33dad1ccc8 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -336,8 +336,11 @@ export class DesktopRuntimeHostClient {
     return this.request('collaboration.turn-request.create', { intent });
   }
 
-  queryCollaborationTurnRequests(sessionId: string): Promise<CollaborationTurnRequestQueryResult> {
-    return this.request('collaboration.turn-request.query', { sessionId });
+  queryCollaborationTurnRequests(sessionId?: string): Promise<CollaborationTurnRequestQueryResult> {
+    return this.request(
+      'collaboration.turn-request.query',
+      sessionId === undefined ? {} : { sessionId },
+    );
   }
 
   acknowledgeCollaborationTurnRequest(
diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
index 9c1ae80b7b..add0f87ef8 100644
--- a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
@@ -94,6 +94,11 @@ export function registerRuntimeHostCollaborationIpc(
     (_event, sessionId: unknown) =>
       client.queryCollaborationTurnRequests(requiredId(sessionId, 'Session')),
   );
+  handleReconnectableRead(
+    ipcMain,
+    'session-collaboration:turn-request:inbox',
+    () => client.queryCollaborationTurnRequests(),
+  );
   ipcMain.handle(
     'session-collaboration:turn-request:acknowledge',
     (_event, requestId: unknown) =>
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 8f89efd7cb..8d9d0dc8a1 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -755,6 +755,8 @@ export interface MakaBridge {
       input: { readonly turnId: string; readonly text: string },
     ): Promise<SessionTurnAccessRequest>;
     getTurnRequests(sessionId: string): Promise<CollaborationTurnRequestQueryResult>;
+    /** Pending Owner decisions across every connected Owner Runtime Host. */
+    getPendingTurnRequests(): Promise<readonly SessionTurnAccessRequest[]>;
     acknowledgeTurnRequest(
       sessionId: string,
       requestId: string,
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 226ce44a52..da6809c8aa 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -222,6 +222,8 @@ import {
   type OperationInput,
   type OperationOutcome,
   type OperationOutput,
+  type CollaborationTurnRequestQueryResult,
+  type SessionTurnAccessRequest,
 } from '@maka/runtime-host/protocol';
 import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client';
 import {
@@ -1331,6 +1333,31 @@ const makaBridge = {
         session.sessionId,
       );
     },
+    async getPendingTurnRequests() {
+      const scopes = (await runtimeHostScopeList()).filter(
+        (scope) => runtimeHostMetadataFor(scope)?.profileAccess === 'owner',
+      );
+      const results = await Promise.all(
+        scopes.map(async (scope) => {
+          const result = await ipcRenderer.invoke(
+            'session-collaboration:turn-request:inbox',
+            scope,
+          ) as CollaborationTurnRequestQueryResult;
+          return result.requests
+            .filter((request) => request.state.kind === 'pending')
+            .map((request): SessionTurnAccessRequest => ({
+              ...request,
+              intent: {
+                ...request.intent,
+                sessionId: recordRuntimeHostSessionScope(scope, request.intent.sessionId),
+              },
+            }));
+        }),
+      );
+      return results
+        .flat()
+        .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
+    },
     async acknowledgeTurnRequest(sessionId, requestId) {
       const session = await runtimeHostSessionRef(sessionId);
       return ipcRenderer.invoke(
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index c882b04f64..1610fbdcbc 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -109,8 +109,8 @@ import {
 import { useNewTaskChoice } from './use-new-task-choice';
 import { SessionCollaborationDialog } from './session-collaboration-dialog';
 import { SessionTurnRequestComposer } from './session-turn-request-composer.js';
+import * as SessionCollaboration from './features/session-collaboration';
 import { getSessionCollaborationCopy } from './locales/session-collaboration-copy';
-import { useSessionCollaborationDialog } from './use-session-collaboration-dialog';
 import { NEW_TASK_PENDING_KEY } from './pending-items';
 import { parseDesktopSlashCommand } from './desktop-slash-command';
 import {
@@ -331,7 +331,7 @@ function AppShellContent({
 }) {
   const toastApi = useToast();
   const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus | null>(null);
-  const sharedSessionDialog = useSessionCollaborationDialog();
+  const sharedSessionDialog = SessionCollaboration.useSessionCollaborationDialog();
   const updateInstallInFlightRef = useRef(false);
   const notifiedInstallErrorRef = useRef<string | null>(null);
   const previousInterruptionShownRef = useRef(false);
@@ -646,6 +646,7 @@ function AppShellContent({
     setUiLocalePreference,
   });
   const shellCopy = getShellCopy(uiLocale).app;
+  const sessionCollaborationCopy = getSessionCollaborationCopy(uiLocale);
   const previousInterruptionCopy =
     getShellRemainingCopy(uiLocale).previousMainProcessInterruption;
   const desktopConversationCopy = getDesktopConversationCopy(uiLocale);
@@ -830,13 +831,6 @@ function AppShellContent({
   const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeMessageSubmitting = transientMessages.length > 0;
   const activeDesktopSession = activeSession;
-  function openSessionSharing(session: DesktopSessionSummary): void {
-    sharedSessionDialog.open({
-      sessionId: session.id,
-      sessionName: session.name,
-      requiresRemoteAccess: session.profileKind === 'local',
-    });
-  }
   // The shell's reading of the active live turn: streaming/settled flags, the
   // in-flight tool signal, and the #646 turn-wait cues, all derived from the
   // semantic snapshot rather than the projection (#1985).
@@ -1654,7 +1648,7 @@ function AppShellContent({
     newSessionPermissionMode: newTaskPermissionMode,
   };
 
-  const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.target !== undefined;
+  const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.isOpen;
   const shellObscured = hasModalOpen || settingsOpen;
   const contextCompactionPresentation = useMemo(
     () =>
@@ -2732,6 +2726,12 @@ function AppShellContent({
       reportError={showSessionError}
     >
     <ComposerMentionsProvider {...composerMentionsSurface}>
+    <SessionCollaboration.SessionTurnRequestInboxProvider
+      sessions={sessions}
+      toast={toastApi}
+      onOpenSession={openSession}
+      copy={sessionCollaborationCopy}
+    >
     <div
       className="appFrame agents-layout-root"
       data-agents-page
@@ -2813,7 +2813,7 @@ function AppShellContent({
                     ? undefined
                     : {
                         label: getSessionCollaborationCopy(uiLocale).shareAction,
-                        onClick: () => void openSessionSharing(activeDesktopSession),
+                        onClick: () => sharedSessionDialog.openSession(activeDesktopSession),
                       }
                 }
                 onRenameSession={(name) => {
@@ -2860,6 +2860,7 @@ function AppShellContent({
             projects={localProjects}
             streamingSessionIds={streamingSessionIds}
             staleSessionIds={staleSessionIds}
+            SessionBadge={SessionCollaboration.SessionTurnRequestBadge}
             ports={sessionNavigationPorts}
             commandsRef={sessionNavigationCommandsRef}
             onExitWorkHub={exitWorkHub}
@@ -2928,6 +2929,11 @@ function AppShellContent({
                 hidden={navSelection.section !== 'sessions'}
                 composer={
                   <>
+                    {!sharedSessionActive && activeId ? (
+                      <SessionCollaboration.SessionTurnRequestApprovalForSession
+                        sessionId={activeId}
+                      />
+                    ) : null}
                     {!sharedSessionActive && navSelection.section === 'sessions' &&
                     activeId &&
                     activeSessionForView &&
@@ -3273,21 +3279,11 @@ function AppShellContent({
       <Goals.GoalHost />
       <TaskEntryHost model={taskEntry.host} />
       <RuntimeHostSshTerminalDialog />
-      {sharedSessionDialog.target ? (
-        <SessionCollaborationDialog
-          mode="share"
-          sessionId={sharedSessionDialog.target.sessionId}
-          sessionName={sharedSessionDialog.target.sessionName}
-          requiresRemoteAccess={sharedSessionDialog.target.requiresRemoteAccess}
-          onEnableRemoteAccess={() => {
-            const copy = getSessionCollaborationCopy(uiLocale);
-            sharedSessionDialog.close();
-            toastApi.info(copy.enableRemoteAccessTitle, copy.enableRemoteAccessBody);
-            openSettingsSection('projects');
-          }}
-          onClose={sharedSessionDialog.close}
-        />
-      ) : null}
+      <SessionCollaborationDialog
+        target={sharedSessionDialog.target}
+        onOpenRemoteAccessSettings={() => openSettingsSection('projects')}
+        onClose={sharedSessionDialog.close}
+      />
 
       <AppShellOverlays
         settingsOpen={settingsOpen}
@@ -3337,6 +3333,7 @@ function AppShellContent({
         onSelectedRuntimeHostProfileIdChange={setSettingsProfileId}
       />
     </div>
+    </SessionCollaboration.SessionTurnRequestInboxProvider>
     </ComposerMentionsProvider>
     </Goals.GoalProvider>
   );
diff --git a/apps/desktop/src/renderer/use-session-collaboration-dialog.ts b/apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts
similarity index 78%
copy from apps/desktop/src/renderer/use-session-collaboration-dialog.ts
copy to apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts
index 27f727f2c1..87de07e793 100644
--- a/apps/desktop/src/renderer/use-session-collaboration-dialog.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts
@@ -30,7 +30,19 @@ export function useSessionCollaborationDialog() {
 
   return {
     target,
+    isOpen: target !== undefined,
     open: setTarget,
+    openSession(session: {
+      readonly id: string;
+      readonly name: string;
+      readonly profileKind: string;
+    }) {
+      setTarget({
+        sessionId: session.id,
+        sessionName: session.name,
+        requiresRemoteAccess: session.profileKind === 'local',
+      });
+    },
     close: () => setTarget(undefined),
   };
 }
diff --git a/apps/desktop/src/renderer/features/session-collaboration/controller/use-turn-request-inbox.ts b/apps/desktop/src/renderer/features/session-collaboration/controller/use-turn-request-inbox.ts
new file mode 100644
index 0000000000..e7618ac395
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-collaboration/controller/use-turn-request-inbox.ts
@@ -0,0 +1,142 @@
+/*
+ * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import type { ToastApi } from '@maka/ui';
+import {
+  groupPendingTurnRequests,
+  samePendingTurnRequests,
+  turnRequestPreview,
+  unseenTurnRequests,
+} from '../model/turn-request-inbox.js';
+import { useSessionCollaborationServices } from '../services-context.js';
+
+const INBOX_REFRESH_INTERVAL_MS = 2_000;
+
+export function useSessionTurnRequestInbox(input: {
+  readonly sessions: readonly { readonly id: string; readonly name: string }[];
+  readonly toast: ToastApi;
+  readonly onOpenSession: (sessionId: string) => void;
+  readonly copy: {
+    readonly sharedTask: string;
+    readonly newTurnRequestTitle: (count: number) => string;
+    readonly newTurnRequestSummary: (count: number) => string;
+    readonly reviewTurnRequest: string;
+    readonly turnRequests: string;
+  };
+}) {
+  const services = useSessionCollaborationServices();
+  const [requests, setRequests] = useState<readonly SessionTurnAccessRequest[]>([]);
+  const [workingRequestIds, setWorkingRequestIds] = useState<ReadonlySet<string>>(
+    () => new Set(),
+  );
+  const seenRequestIdsRef = useRef(new Set<string>());
+  const latestRef = useRef(input);
+  latestRef.current = input;
+
+  const applyProjection = useCallback((next: readonly SessionTurnAccessRequest[]) => {
+    const unseen = unseenTurnRequests(next, seenRequestIdsRef.current);
+    for (const request of next) seenRequestIdsRef.current.add(request.requestId);
+    setRequests((current) => samePendingTurnRequests(current, next) ? current : next);
+    if (unseen.length === 0) return;
+
+    const first = unseen[0]!;
+    const latest = latestRef.current;
+    const copy = latest.copy;
+    const sessionName = latest.sessions.find(
+      (session) => session.id === first.intent.sessionId,
+    )?.name ?? copy.sharedTask;
+    latest.toast.toast({
+      variant: 'warning',
+      title: copy.newTurnRequestTitle(unseen.length),
+      description: unseen.length === 1
+        ? `${sessionName} · ${turnRequestPreview(first.intent.content.text)}`
+        : copy.newTurnRequestSummary(unseen.length),
+      duration: 10_000,
+      action: {
+        label: copy.reviewTurnRequest,
+        onClick: () => latest.onOpenSession(first.intent.sessionId),
+      },
+    });
+  }, []);
+
+  const refresh = useCallback(async () => {
+    const next = await services.getPendingTurnRequests();
+    applyProjection(next);
+  }, [applyProjection, services]);
+
+  useEffect(() => {
+    let disposed = false;
+    let timer: number | undefined;
+    const poll = async () => {
+      try {
+        const next = await services.getPendingTurnRequests();
+        if (!disposed) applyProjection(next);
+      } catch {
+        // Keep the last durable projection while a Host reconnects and retry.
+      } finally {
+        if (!disposed) timer = window.setTimeout(() => void poll(), INBOX_REFRESH_INTERVAL_MS);
+      }
+    };
+    void poll();
+    return () => {
+      disposed = true;
+      if (timer !== undefined) window.clearTimeout(timer);
+    };
+  }, [applyProjection, services]);
+
+  const decide = useCallback(async (
+    request: SessionTurnAccessRequest,
+    decision: 'approve' | 'reject',
+  ) => {
+    setWorkingRequestIds((current) => new Set(current).add(request.requestId));
+    try {
+      await services.decideTurnRequest(
+        request.intent.sessionId,
+        request.requestId,
+        decision,
+      );
+      setRequests((current) => current.filter(
+        (candidate) => candidate.requestId !== request.requestId,
+      ));
+      await refresh();
+    } catch (error) {
+      const copy = latestRef.current.copy;
+      latestRef.current.toast.error(copy.turnRequests, errorMessage(error));
+    } finally {
+      setWorkingRequestIds((current) => {
+        const next = new Set(current);
+        next.delete(request.requestId);
+        return next;
+      });
+    }
+  }, [refresh]);
+
+  return {
+    requests,
+    requestsBySession: useMemo(() => groupPendingTurnRequests(requests), [requests]),
+    workingRequestIds,
+    decide,
+  };
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
diff --git a/apps/desktop/src/renderer/features/session-collaboration/index.ts b/apps/desktop/src/renderer/features/session-collaboration/index.ts
index d5c0973e3e..4b8c09257b 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/index.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/index.ts
@@ -18,5 +18,15 @@
  */
 
 export { SessionCollaborationServicesProvider } from './services-context';
+export {
+  useSessionCollaborationDialog,
+  type SessionCollaborationDialogTarget,
+} from './controller/use-session-collaboration-dialog';
 export { SessionCollaborationJoinDialog } from './ui/session-collaboration-join-dialog';
+export { SessionTurnRequestApprovalForSession } from './ui/session-turn-request-approval';
+export { SessionTurnRequestBadge } from './ui/session-turn-request-badge';
+export {
+  SessionTurnRequestInboxProvider,
+  type SessionTurnRequestInboxCopy,
+} from './turn-request-inbox-context';
 export type { SessionCollaborationServices } from './ports';
diff --git a/apps/desktop/src/renderer/features/session-collaboration/model/turn-request-inbox.ts b/apps/desktop/src/renderer/features/session-collaboration/model/turn-request-inbox.ts
new file mode 100644
index 0000000000..387297f4ff
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-collaboration/model/turn-request-inbox.ts
@@ -0,0 +1,62 @@
+/*
+ * 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 type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+
+export function groupPendingTurnRequests(
+  requests: readonly SessionTurnAccessRequest[],
+): ReadonlyMap<string, readonly SessionTurnAccessRequest[]> {
+  const grouped = new Map<string, SessionTurnAccessRequest[]>();
+  for (const request of requests) {
+    if (request.state.kind !== 'pending') continue;
+    const sessionId = request.intent.sessionId;
+    const sessionRequests = grouped.get(sessionId) ?? [];
+    sessionRequests.push(request);
+    grouped.set(sessionId, sessionRequests);
+  }
+  return grouped;
+}
+
+export function unseenTurnRequests(
+  requests: readonly SessionTurnAccessRequest[],
+  seenRequestIds: ReadonlySet<string>,
+): readonly SessionTurnAccessRequest[] {
+  return requests.filter(
+    (request) => request.state.kind === 'pending' && !seenRequestIds.has(request.requestId),
+  );
+}
+
+export function samePendingTurnRequests(
+  left: readonly SessionTurnAccessRequest[],
+  right: readonly SessionTurnAccessRequest[],
+): boolean {
+  return left.length === right.length && left.every((request, index) => {
+    const candidate = right[index];
+    return candidate !== undefined &&
+      request.requestId === candidate.requestId &&
+      request.intent.sessionId === candidate.intent.sessionId &&
+      request.intent.content.text === candidate.intent.content.text;
+  });
+}
+
+export function turnRequestPreview(text: string, maxLength = 120): string {
+  const collapsed = text.replace(/\s+/gu, ' ').trim();
+  if (collapsed.length <= maxLength) return collapsed;
+  return `${collapsed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
+}
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ports.ts b/apps/desktop/src/renderer/features/session-collaboration/ports.ts
index 6079577ad3..c8c045f679 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/ports.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/ports.ts
@@ -23,6 +23,10 @@ import type {
   SessionCollaborationImportResult,
   SessionCollaborationMountSummary,
 } from '../../../shared/session-collaboration.js';
+import type {
+  CollaborationTurnRequestDecideResult,
+  SessionTurnAccessRequest,
+} from '@maka/runtime-host/protocol';
 
 export type {
   SessionCollaborationCancelResult,
@@ -40,5 +44,11 @@ export interface SessionCollaborationServices {
   cancelImport(operationId: string): Promise<SessionCollaborationCancelResult>;
   listMounts(): Promise<readonly SessionCollaborationMountSummary[]>;
   removeMount(mountId: string): Promise<void>;
+  getPendingTurnRequests(): Promise<readonly SessionTurnAccessRequest[]>;
+  decideTurnRequest(
+    sessionId: string,
+    requestId: string,
+    decision: 'approve' | 'reject',
+  ): Promise<CollaborationTurnRequestDecideResult>;
   createOperationId(): string;
 }
diff --git a/apps/desktop/src/renderer/features/session-collaboration/index.ts b/apps/desktop/src/renderer/features/session-collaboration/testing.ts
similarity index 78%
copy from apps/desktop/src/renderer/features/session-collaboration/index.ts
copy to apps/desktop/src/renderer/features/session-collaboration/testing.ts
index d5c0973e3e..a1d8da6866 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/index.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/testing.ts
@@ -17,6 +17,9 @@
  * under the License.
  */
 
-export { SessionCollaborationServicesProvider } from './services-context';
-export { SessionCollaborationJoinDialog } from './ui/session-collaboration-join-dialog';
-export type { SessionCollaborationServices } from './ports';
+export {
+  groupPendingTurnRequests,
+  samePendingTurnRequests,
+  turnRequestPreview,
+  unseenTurnRequests,
+} from './model/turn-request-inbox.js';
diff --git a/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx
new file mode 100644
index 0000000000..2b7af3eb61
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx
@@ -0,0 +1,72 @@
+/*
+ * 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 { createContext, useContext, useMemo, type ReactNode } from 'react';
+import type { ToastApi } from '@maka/ui';
+import { useSessionTurnRequestInbox } from './controller/use-turn-request-inbox.js';
+
+export interface SessionTurnRequestInboxCopy {
+  readonly sharedTask: string;
+  readonly newTurnRequestTitle: (count: number) => string;
+  readonly newTurnRequestSummary: (count: number) => string;
+  readonly reviewTurnRequest: string;
+  readonly turnRequests: string;
+  readonly ownerTurnRequestTitle: string;
+  readonly reject: string;
+  readonly approve: string;
+  readonly moreTurnRequests: (count: number) => string;
+  readonly pendingTurnRequestCount: (count: number) => string;
+}
+
+type SessionTurnRequestInbox = ReturnType<typeof useSessionTurnRequestInbox> & {
+  readonly copy: SessionTurnRequestInboxCopy;
+};
+
+const SessionTurnRequestInboxContext = createContext<SessionTurnRequestInbox | null>(null);
+
+export function SessionTurnRequestInboxProvider(props: {
+  readonly sessions: readonly { readonly id: string; readonly name: string }[];
+  readonly toast: ToastApi;
+  readonly onOpenSession: (sessionId: string) => void;
+  readonly copy: SessionTurnRequestInboxCopy;
+  readonly children?: ReactNode;
+}) {
+  const inbox = useSessionTurnRequestInbox(props);
+  const value = useMemo<SessionTurnRequestInbox>(
+    () => ({ ...inbox, copy: props.copy }),
+    [
+      inbox.decide,
+      inbox.requests,
+      inbox.requestsBySession,
+      inbox.workingRequestIds,
+      props.copy,
+    ],
+  );
+  return (
+    <SessionTurnRequestInboxContext.Provider value={value}>
+      {props.children}
+    </SessionTurnRequestInboxContext.Provider>
+  );
+}
+
+export function useSessionTurnRequestInboxContext(): SessionTurnRequestInbox {
+  const inbox = useContext(SessionTurnRequestInboxContext);
+  if (!inbox) throw new Error('SessionTurnRequestInboxProvider is missing');
+  return inbox;
+}
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
new file mode 100644
index 0000000000..da35f7a276
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
@@ -0,0 +1,88 @@
+/*
+ * 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 type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import { Banner, Button } from '@maka/ui';
+import {
+  useSessionTurnRequestInboxContext,
+  type SessionTurnRequestInboxCopy,
+} from '../turn-request-inbox-context.js';
+
+export function SessionTurnRequestApprovalForSession(props: { readonly sessionId: string }) {
+  const inbox = useSessionTurnRequestInboxContext();
+  return (
+    <SessionTurnRequestApproval
+      requests={inbox.requestsBySession.get(props.sessionId) ?? []}
+      workingRequestIds={inbox.workingRequestIds}
+      copy={inbox.copy}
+      onDecide={inbox.decide}
+    />
+  );
+}
+
+export function SessionTurnRequestApproval(props: {
+  readonly requests: readonly SessionTurnAccessRequest[];
+  readonly workingRequestIds: ReadonlySet<string>;
+  readonly copy: Pick<
+    SessionTurnRequestInboxCopy,
+    'ownerTurnRequestTitle' | 'reject' | 'approve' | 'moreTurnRequests'
+  >;
+  readonly onDecide: (
+    request: SessionTurnAccessRequest,
+    decision: 'approve' | 'reject',
+  ) => void | Promise<void>;
+}) {
+  const request = props.requests[0];
+  const copy = props.copy;
+  if (!request) return null;
+  const working = props.workingRequestIds.has(request.requestId);
+  return (
+    <div className="sessionTurnRequestApproval">
+      <Banner
+        status="warning"
+        role="status"
+        title={copy.ownerTurnRequestTitle}
+        description={request.intent.content.text}
+        endContent={(
+          <div className="sessionTurnRequestApprovalActions">
+            <Button
+              variant="secondary"
+              size="sm"
+              label={copy.reject}
+              isDisabled={working}
+              onClick={() => void props.onDecide(request, 'reject')}
+            />
+            <Button
+              variant="primary"
+              size="sm"
+              label={copy.approve}
+              isDisabled={working}
+              onClick={() => void props.onDecide(request, 'approve')}
+            />
+          </div>
+        )}
+      />
+      {props.requests.length > 1 ? (
+        <span className="sessionTurnRequestApprovalMore">
+          {copy.moreTurnRequests(props.requests.length - 1)}
+        </span>
+      ) : null}
+    </div>
+  );
+}
diff --git a/apps/desktop/src/renderer/use-session-collaboration-dialog.ts b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-badge.tsx
similarity index 60%
rename from apps/desktop/src/renderer/use-session-collaboration-dialog.ts
rename to apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-badge.tsx
index 27f727f2c1..85ec054cdd 100644
--- a/apps/desktop/src/renderer/use-session-collaboration-dialog.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-badge.tsx
@@ -17,20 +17,16 @@
  * under the License.
  */
 
-import { useState } from 'react';
+import { Badge } from '@maka/ui';
+import { useSessionTurnRequestInboxContext } from '../turn-request-inbox-context.js';
 
-export interface SessionCollaborationDialogTarget {
-  readonly sessionId: string;
-  readonly sessionName: string;
-  readonly requiresRemoteAccess: boolean;
-}
-
-export function useSessionCollaborationDialog() {
-  const [target, setTarget] = useState<SessionCollaborationDialogTarget>();
-
-  return {
-    target,
-    open: setTarget,
-    close: () => setTarget(undefined),
-  };
+export function SessionTurnRequestBadge(props: { readonly sessionId: string }) {
+  const inbox = useSessionTurnRequestInboxContext();
+  const count = inbox.requestsBySession.get(props.sessionId)?.length ?? 0;
+  if (count === 0) return null;
+  return (
+    <span title={inbox.copy.pendingTurnRequestCount(count)}>
+      <Badge variant="warning" label={count > 99 ? '99+' : String(count)} />
+    </span>
+  );
 }
diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
index 61734b865c..652b73e377 100644
--- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
+++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
@@ -17,7 +17,13 @@
  * under the License.
  */
 
-import { useLayoutEffect, useMemo, useRef, type ReactNode } from 'react';
+import {
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  type ComponentType,
+  type ReactNode,
+} from 'react';
 import type { ProjectRecord } from '@maka/core/project';
 import type { ScheduledTask } from '@maka/core/scheduled-task';
 import {
@@ -65,6 +71,7 @@ export interface SessionNavigationProviderProps extends SessionNavigationChromeI
   projects: readonly ProjectRecord[];
   streamingSessionIds: ReadonlySet<string>;
   staleSessionIds: ReadonlySet<string>;
+  SessionBadge?: ComponentType<{ readonly sessionId: string }>;
   ports: SessionNavigationPorts;
   /**
    * Where the shell reads the row mutations it issues from elsewhere — the
@@ -150,19 +157,25 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
   );
 
   const data = useMemo<SessionRailData>(
-    () => ({
-      sessions: props.rail.sessions,
-      activeId: props.workHubActive ? undefined : props.rail.activeRowId,
-      streamingSessionIds: props.streamingSessionIds,
-      staleSessionIds: props.staleSessionIds,
-      worktreeSessionIds: controller.selectors.worktreeSessionIds,
-      groups: controller.layout.viewMode === 'project' ? controller.selectors.groups : undefined,
-      groupVariant: controller.layout.viewMode,
-      sessionMeta: controller.selectors.sessionMeta,
-      onSelectSession: props.onSelectSession,
-      rowActions,
-      projectActions,
-    }),
+    () => {
+      const SessionBadge = props.SessionBadge;
+      return {
+        sessions: props.rail.sessions,
+        activeId: props.workHubActive ? undefined : props.rail.activeRowId,
+        streamingSessionIds: props.streamingSessionIds,
+        staleSessionIds: props.staleSessionIds,
+        worktreeSessionIds: controller.selectors.worktreeSessionIds,
+        groups: controller.layout.viewMode === 'project' ? controller.selectors.groups : undefined,
+        groupVariant: controller.layout.viewMode,
+        sessionMeta: controller.selectors.sessionMeta,
+        sessionBadge: SessionBadge
+          ? (session) => <SessionBadge sessionId={session.id} />
+          : undefined,
+        onSelectSession: props.onSelectSession,
+        rowActions,
+        projectActions,
+      };
+    },
     [
       controller.layout.viewMode,
       controller.selectors.groups,
@@ -171,6 +184,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps)
       props.onSelectSession,
       projectActions,
       props.rail,
+      props.SessionBadge,
       props.staleSessionIds,
       props.streamingSessionIds,
       props.workHubActive,
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
index 9b0cf33a53..9e07ced8d3 100644
--- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -90,10 +90,21 @@ const ZH = {
     turnRequestBlocked: '未能开始',
     turnRequestFailed: '准入失败',
     dismissTurnRequest: '关闭',
+    sharedTask: '共享任务',
+    newTurnRequestTitle: (count: number) => count === 1 ? '新的轮次请求' : `${count} 个新的轮次请求`,
+    newTurnRequestSummary: (count: number) => `${count} 个轮次请求正在等待审批`,
+    reviewTurnRequest: '查看并审批',
+    ownerTurnRequestTitle: '访客请求开始新轮次',
+    moreTurnRequests: (count: number) => `还有 ${count} 个请求等待审批`,
+    pendingTurnRequestCount: (count: number) => `${count} 个待审批轮次请求`,
 };
 
 type SessionCollaborationCopy = {
-  readonly [Key in keyof typeof ZH]: string;
+  readonly [Key in keyof typeof ZH]: (typeof ZH)[Key] extends (
+    ...args: infer Args
+  ) => string
+    ? (...args: Args) => string
+    : string;
 };
 
 const EN = {
@@ -168,6 +179,13 @@ const EN = {
     turnRequestBlocked: 'Could not start',
     turnRequestFailed: 'Admission failed',
     dismissTurnRequest: 'Dismiss',
+    sharedTask: 'Shared task',
+    newTurnRequestTitle: (count: number) => count === 1 ? 'New Turn request' : `${count} new Turn requests`,
+    newTurnRequestSummary: (count: number) => `${count} Turn requests are waiting for approval`,
+    reviewTurnRequest: 'Review',
+    ownerTurnRequestTitle: 'A Guest requested a new Turn',
+    moreTurnRequests: (count: number) => `${count} more ${count === 1 ? 'request' : 'requests'} waiting`,
+    pendingTurnRequestCount: (count: number) => `${count} pending Turn ${count === 1 ? 'request' : 'requests'}`,
 } satisfies SessionCollaborationCopy;
 
 const COPY = { zh: ZH, en: EN } satisfies UiCatalog<SessionCollaborationCopy>;
diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts
index 249eec9d80..850084e191 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts
@@ -31,6 +31,9 @@ export function createDesktopSessionCollaborationServices(
     cancelImport: (operationId) => bridge.sessionCollaboration.cancelImport(operationId),
     listMounts: () => bridge.sessionCollaboration.listMounts(),
     removeMount: (mountId) => bridge.sessionCollaboration.removeMount(mountId),
+    getPendingTurnRequests: () => bridge.sessionCollaboration.getPendingTurnRequests(),
+    decideTurnRequest: (sessionId, requestId, decision) =>
+      bridge.sessionCollaboration.decideTurnRequest(sessionId, requestId, decision),
     createOperationId: () => crypto.randomUUID(),
   };
 }
diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
index 7f17533cf3..06c85e9634 100644
--- a/apps/desktop/src/renderer/session-collaboration-dialog.tsx
+++ b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
@@ -32,38 +32,49 @@ import {
 } from '@maka/ui';
 import type {
   CollaborationAccessQueryResult,
+  CollaborationInvitationPrepareResult,
   SessionCollaborationGrant,
   SessionTurnAccessRequest,
 } from '@maka/runtime-host/protocol';
-import type { DesktopSessionCollaborationPrepareResult } from '../preload/bridge-contract.js';
 import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js';
 import { turnRequestStateLabel } from './session-turn-request-composer.js';
 
 type Props = {
-  readonly mode: 'share';
-  readonly sessionId: string;
-  readonly sessionName: string;
-  readonly requiresRemoteAccess: boolean;
-  readonly onEnableRemoteAccess: () => void;
+  readonly target?: {
+    readonly sessionId: string;
+    readonly sessionName: string;
+    readonly requiresRemoteAccess: boolean;
+  };
+  readonly onOpenRemoteAccessSettings: () => void;
   readonly onClose: () => void;
 };
 
+type ShareSessionDialogProps = NonNullable<Props['target']> & Omit<Props, 'target'>;
+
 type CollaborationAuthorityState =
   | 'loading'
   | 'available'
   | 'remote_access_off'
   | 'unavailable';
 
-type PreparedInvitation = Extract<
-  DesktopSessionCollaborationPrepareResult,
-  { readonly kind: 'prepared' }
->['invitation'];
+type PreparedInvitation = CollaborationInvitationPrepareResult & {
+  readonly connectivity:
+    | { readonly kind: 'peer'; readonly coordinationRelayCount: number }
+    | { readonly kind: 'configured' };
+};
 
 export function SessionCollaborationDialog(props: Props) {
-  return <ShareSessionDialog {...props} />;
+  if (!props.target) return null;
+  return (
+    <ShareSessionDialog
+      {...props.target}
+      onOpenRemoteAccessSettings={props.onOpenRemoteAccessSettings}
+      onClose={props.onClose}
+    />
+  );
 }
 
-function ShareSessionDialog(props: Props) {
+function ShareSessionDialog(props: ShareSessionDialogProps) {
   const copy = getSessionCollaborationCopy(useUiLocale());
   const toast = useToast();
   const [preset, setPreset] = useState<'observe' | 'request_turn'>('observe');
@@ -138,14 +149,14 @@ function ShareSessionDialog(props: Props) {
     setWorking(true);
     try {
       if (authorityState === 'remote_access_off') {
-        props.onEnableRemoteAccess();
+        openRemoteAccessSettings();
         return;
       }
       if (authorityState !== 'available') return;
       if (props.requiresRemoteAccess) {
         const access = await window.maka.localRuntimeHostRemoteAccess.getSnapshot();
         if (access.state !== 'on') {
-          props.onEnableRemoteAccess();
+          openRemoteAccessSettings();
           return;
         }
       }
@@ -173,6 +184,12 @@ function ShareSessionDialog(props: Props) {
     }
   }
 
+  function openRemoteAccessSettings(): void {
+    props.onClose();
+    toast.info(copy.enableRemoteAccessTitle, copy.enableRemoteAccessBody);
+    props.onOpenRemoteAccessSettings();
+  }
+
   async function copyInvitation(): Promise<void> {
     if (!invitation) return;
     try {
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css
index 9720272a21..9ab0e91944 100644
--- a/apps/desktop/src/renderer/styles/shell-layout.css
+++ b/apps/desktop/src/renderer/styles/shell-layout.css
@@ -587,6 +587,25 @@ dialog:modal {
   background: var(--background);
 }
 
+.sessionTurnRequestApproval {
+  display: grid;
+  gap: var(--space-1);
+  padding: var(--space-2) var(--space-3) 0;
+  background: var(--background);
+}
+
+.sessionTurnRequestApprovalActions {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-1);
+}
+
+.sessionTurnRequestApprovalMore {
+  color: var(--foreground-secondary);
+  font: var(--typography-caption);
+  text-align: end;
+}
+
 .sessionTurnRequestComposer {
   padding: var(--space-3);
 }
diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css
index bc56960aa3..4b1aadcaf2 100644
--- a/apps/desktop/src/renderer/styles/sidebar.css
+++ b/apps/desktop/src/renderer/styles/sidebar.css
@@ -358,6 +358,11 @@
   max-width: 88px;
 }
 
+.maka-session-row-attention-badge {
+  display: inline-flex;
+  flex: none;
+}
+
 .maka-session-row-host-badge > span {
   overflow: hidden;
   max-width: 100%;
diff --git a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
index c94f198afb..13a3185638 100644
--- a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
+++ b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
@@ -156,6 +156,52 @@ test('Turn requests reject execution input that the Owner cannot review', () =>
   );
 });
 
+test('Owner can query one durable Turn-request inbox without exposing another Guest', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-inbox-'));
+  const authority = await openRuntimeHostAccessAuthority(directory);
+  try {
+    assert.deepEqual(HOST_OPERATION_SPECS['collaboration.turn-request.query'].decodeInput({}), {});
+    const firstGuest = await activateTurnGuest(authority, 'session-1');
+    const secondGuest = await activateTurnGuest(authority, 'session-2');
+    await authority.createTurnAccessRequest(firstGuest, {
+      intent: {
+        sessionId: 'session-1',
+        turnId: 'turn-1',
+        content: { text: 'First request' },
+      },
+    });
+    await authority.createTurnAccessRequest(secondGuest, {
+      intent: {
+        sessionId: 'session-2',
+        turnId: 'turn-2',
+        content: { text: 'Second request' },
+      },
+    });
+
+    assert.deepEqual(
+      authority
+        .queryTurnAccessRequests(LOCAL_OWNER, {})
+        .requests.map((request) => request.intent.sessionId),
+      ['session-1', 'session-2'],
+    );
+    assert.deepEqual(
+      authority
+        .queryTurnAccessRequests(sessionGuest(firstGuest), {})
+        .requests.map((request) => request.intent.sessionId),
+      ['session-1'],
+    );
+    assert.deepEqual(
+      authority
+        .queryTurnAccessRequests(LOCAL_OWNER, { sessionId: 'session-2' })
+        .requests.map((request) => request.intent.sessionId),
+      ['session-2'],
+    );
+  } finally {
+    await authority.close();
+    await rm(directory, { recursive: true, force: true });
+  }
+});
+
 test('an approved exact Turn request survives restart and is admitted once', async () => {
   const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-'));
   let authority = await openRuntimeHostAccessAuthority(directory);
@@ -592,9 +638,12 @@ test('drain does not terminalize an in-flight admission failure', async () => {
   }
 });
 
-async function activateTurnGuest(authority: RuntimeHostAccessAuthority): Promise<string> {
+async function activateTurnGuest(
+  authority: RuntimeHostAccessAuthority,
+  sessionId = 'session-1',
+): Promise<string> {
   const prepared = await authority.prepareCollaborationInvitation('root-1', {
-    sessionId: 'session-1',
+    sessionId,
     grantKinds: ['session_turn_request'],
   });
   const invitation = decodeCollaborationInvitationCode(prepared.invitationCode);
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 8ad38a70d4..04a0522062 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 88 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 89 as const;
+// 89: Owners can query their complete pending Session Turn-request inbox.
 // 88: Host status publishes the live Direct peer endpoint so newly issued
 // connection invitations do not preserve stale startup routes.
 // 87: The connection catalog projects each model as the Host resolved it —
diff --git a/packages/runtime-host/src/protocol/session-collaboration.ts b/packages/runtime-host/src/protocol/session-collaboration.ts
index cdfc55df09..81eac55fdb 100644
--- a/packages/runtime-host/src/protocol/session-collaboration.ts
+++ b/packages/runtime-host/src/protocol/session-collaboration.ts
@@ -136,7 +136,8 @@ export interface CollaborationTurnRequestCreateInput {
 }
 
 export interface CollaborationTurnRequestQueryInput {
-  readonly sessionId: string;
+  /** Omit to query every request visible to the authenticated principal. */
+  readonly sessionId?: string;
 }
 
 export interface CollaborationTurnRequestQueryResult {
@@ -424,8 +425,10 @@ function decodeCollaborationTurnRequestCreateInput(
 function decodeCollaborationTurnRequestQueryInput(
   value: unknown,
 ): CollaborationTurnRequestQueryInput {
-  const record = requireExactRecord(value, 'collaboration Turn request query', ['sessionId']);
-  return { sessionId: requireEntityId(record.sessionId, 'sessionId') };
+  const record = requireShapedRecord(value, 'collaboration Turn request query', [], ['sessionId']);
+  return record.sessionId === undefined
+    ? {}
+    : { sessionId: requireEntityId(record.sessionId, 'sessionId') };
 }
 
 function decodeCollaborationTurnRequestQueryResult(
diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts
index 2d7176ce28..f25fbc0e0a 100644
--- a/packages/runtime-host/src/server/access-authority.ts
+++ b/packages/runtime-host/src/server/access-authority.ts
@@ -439,12 +439,13 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
     return {
       canRequestTurns:
         guest &&
+        input.sessionId !== undefined &&
         this.activeSessionGrant(principal.principalId, input.sessionId, 'session_turn_request') !==
           undefined,
       requests: this.#file.turnAccessRequests.filter(
         (request) =>
           (!guest || request.principalId === principal.principalId) &&
-          request.intent.sessionId === input.sessionId,
+          (input.sessionId === undefined || request.intent.sessionId === input.sessionId),
       ),
     };
   }
diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx
index 944d6e2f53..e9fd62cdb5 100644
--- a/packages/ui/src/session-history-list.tsx
+++ b/packages/ui/src/session-history-list.tsx
@@ -286,6 +286,7 @@ function SessionListGroups(props: {
         stale={rail.staleSessionIds?.has(session.id) ?? false}
         worktree={rail.worktreeSessionIds?.has(session.id) ?? false}
         meta={rail.sessionMeta?.(session)}
+        badge={rail.sessionBadge?.(session)}
         onSelectSession={rail.onSelectSession}
         actions={(session as SessionSummary & { readonly shared?: true }).shared
           ? undefined
@@ -490,6 +491,7 @@ const SessionNavRow = memo(function SessionNavRow(props: {
   stale: boolean;
   worktree: boolean;
   meta?: string;
+  badge?: ReactNode;
   onSelectSession(sessionId: string): void;
   actions?: SessionRowActions;
   onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void;
@@ -604,6 +606,9 @@ const SessionNavRow = memo(function SessionNavRow(props: {
           // the two on hover or keyboard focus. The span is rendered even with
           // no timestamp so the column exists on every row.
           <span className="maka-session-row-end">
+            {props.badge ? (
+              <span className="maka-session-row-attention-badge">{props.badge}</span>
+            ) : null}
             {props.meta ? (
               <span className="maka-session-row-host-badge" title={props.meta}>
                 <Badge variant="neutral" label={props.meta} />
diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx
index ca747c830b..17bdcc3f85 100644
--- a/packages/ui/src/session-rail-context.tsx
+++ b/packages/ui/src/session-rail-context.tsx
@@ -52,6 +52,7 @@ export interface SessionRailData {
   groups?: ReadonlyArray<SessionHistoryGroup>;
   groupVariant: SessionViewMode;
   sessionMeta?(session: SessionSummary): string | undefined;
+  sessionBadge?(session: SessionSummary): ReactNode;
   onSelectSession(sessionId: string): void;
   rowActions?: SessionRowActions;
   projectActions?: ProjectRowActions;


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


(maka) 07/11: fix(desktop): preserve available collaboration inboxes

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit a35bbb5d3f963c7dbc0ad9f7e5390454461c9167
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 19:32:14 2026 +0800

    fix(desktop): preserve available collaboration inboxes
---
 apps/desktop/renderer-architecture.json            |  2 +-
 .../runtime-host-collaboration-ipc-main.test.ts    | 14 +++-
 .../runtime-host-guest-session-mounts.test.ts      | 11 ++-
 .../runtime-host-session-catalog-preload.test.ts   | 62 +++++++++++----
 ...runtime-host-turn-request-inbox-preload.test.ts | 49 ++++++++++++
 .../main/runtime-host-collaboration-ipc-main.ts    |  9 +--
 .../src/main/runtime-host-guest-session-mounts.ts  |  2 +
 apps/desktop/src/preload/bridge-contract.d.ts      |  1 -
 apps/desktop/src/preload/preload.ts                | 91 +++++++++-------------
 .../src/preload/runtime-host-session-catalog.ts    | 32 ++++----
 .../src/preload/runtime-host-turn-request-inbox.ts | 38 +++++++++
 apps/desktop/src/renderer/app-shell.tsx            | 18 ++---
 .../renderer/locales/session-collaboration-copy.ts |  4 +-
 native/runtime-host-peer/src/engine.rs             | 81 +++++++++++++++++--
 .../__tests__/execution-service-listeners.test.ts  | 17 ++--
 .../runtime-host/src/__tests__/peer-native.test.ts | 30 ++++---
 packages/runtime-host/src/client/connection.ts     |  5 --
 packages/runtime-host/src/client/peer-client.ts    | 10 ---
 .../runtime-host/src/server/execution-service.ts   |  8 +-
 packages/runtime-host/src/server/listener-set.ts   | 23 +++---
 20 files changed, 339 insertions(+), 168 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index c73082f51d..4cc66f0986 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -1059,7 +1059,7 @@
           "react": 1
         },
         "importSpecifiers": 186,
-        "nonTriviaTokens": 15795
+        "nonTriviaTokens": 15800
       },
       "src/renderer/use-app-shell-composer-quotes.ts": {
         "importDeclarations": 3,
diff --git a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
index 2ab160b0ee..ce64f26182 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
@@ -37,6 +37,7 @@ test('requires plaintext confirmation and reports the issued invitation routes',
     },
   };
   let prepareCalls = 0;
+  const queryCalls: Array<string | undefined> = [];
   const client = {
     async prepareCollaborationInvitation(sessionId: string, grantKinds: readonly string[]) {
       prepareCalls += 1;
@@ -57,7 +58,7 @@ test('requires plaintext confirmation and reports the issued invitation routes',
       return { principals: [], grants: [] };
     },
     async queryCollaborationTurnRequests(sessionId?: string) {
-      assert.equal(sessionId, undefined);
+      queryCalls.push(sessionId);
       return { canRequestTurns: false, requests: [] };
     },
     async revokeCollaborationPrincipal() {
@@ -103,12 +104,17 @@ test('requires plaintext confirmation and reports the issued invitation routes',
   assert.equal(decodeCollaborationInvitationCode(bundle.invitationCode).rootId, ROOT_ID);
   assert.equal(bundle.target.transport.kind, 'plaintext');
 
-  const inbox = handlers.get('session-collaboration:turn-request:inbox');
-  assert.ok(inbox);
-  assert.deepEqual(await inbox({} as Parameters<IpcHandler>[0]), {
+  const query = handlers.get('session-collaboration:turn-request:query');
+  assert.ok(query);
+  assert.deepEqual(await query({} as Parameters<IpcHandler>[0]), {
     canRequestTurns: false,
     requests: [],
   });
+  assert.deepEqual(await query({} as Parameters<IpcHandler>[0], 'session-1'), {
+    canRequestTurns: false,
+    requests: [],
+  });
+  assert.deepEqual(queryCalls, [undefined, 'session-1']);
 
   const peerHandlers = new Map<string, IpcHandler>();
   registerRuntimeHostCollaborationIpc(
diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
index 7f86dd6f72..49706eb3ce 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
@@ -278,7 +278,8 @@ test('reads an invitation from the clipboard only on explicit IPC invocation', a
   type IpcHandler = Parameters<Pick<Electron.IpcMain, 'handle'>['handle']>[1];
   const handlers = new Map<string, IpcHandler>();
   let clipboardReads = 0;
-  let clipboardText = '  invitation-code  ';
+  const clipboardInvitation = invitation('guest-clipboard');
+  let clipboardText = `  ${clipboardInvitation}  `;
   const mounts = service(memoryStore());
   const dispose = registerDesktopGuestSessionMountIpc(
     {
@@ -299,9 +300,15 @@ test('reads an invitation from the clipboard only on explicit IPC invocation', a
   assert.equal(clipboardReads, 0);
   const handler = handlers.get('session-collaboration:invitation:read-clipboard');
   assert.ok(handler);
-  assert.equal(await handler({} as never), 'invitation-code');
+  assert.equal(await handler({} as never), clipboardInvitation);
   assert.equal(clipboardReads, 1);
 
+  clipboardText = 'an unrelated clipboard secret';
+  assert.throws(() => handler({} as never), /Invalid Desktop collaboration invitation/);
+
+  clipboardText = '   ';
+  assert.equal(await handler({} as never), '');
+
   clipboardText = 'x'.repeat(32 * 1024 + 1);
   assert.throws(
     () => handler({} as never),
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts
index 3274cf1f0c..bed0ef61e3 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts
@@ -21,9 +21,9 @@ import assert from 'node:assert/strict';
 import test from 'node:test';
 import type { DesktopSessionSummary } from '../../preload/bridge-contract.js';
 import {
-  collectRuntimeHostSessionCatalogs,
   collectRuntimeHostSessionCatalogsWithCoverage,
   reconcileRuntimeHostSessionCatalog,
+  resolveRuntimeHostSessionCatalog,
 } from '../../preload/runtime-host-session-catalog.js';
 
 function session(id: string, activityAt: number): DesktopSessionSummary {
@@ -31,13 +31,17 @@ function session(id: string, activityAt: number): DesktopSessionSummary {
 }
 
 test('keeps healthy Host catalogs when another Host rejects', async () => {
-  const sessions = await collectRuntimeHostSessionCatalogs([
-    Promise.resolve([session('older', 1)]),
-    Promise.reject(new Error('remote unavailable')),
-    Promise.resolve([session('newer', 2)]),
+  const catalog = await collectRuntimeHostSessionCatalogsWithCoverage([
+    { hostId: 'older', access: 'owner', sessions: Promise.resolve([session('older', 1)]) },
+    {
+      hostId: 'unavailable',
+      access: 'owner',
+      sessions: Promise.reject(new Error('remote unavailable')),
+    },
+    { hostId: 'newer', access: 'owner', sessions: Promise.resolve([session('newer', 2)]) },
   ]);
 
-  assert.deepEqual(sessions.map(({ id }) => id), ['newer', 'older']);
+  assert.deepEqual(catalog.sessions.map(({ id }) => id), ['newer', 'older']);
 });
 
 test('reports exactly which Host catalogs are complete', async () => {
@@ -58,12 +62,12 @@ test('collapses overlapping Guest catalogs in favor of the Owner authority', asy
   const owner = session('shared-session', 2);
   const guest = { ...owner, shared: true as const };
 
-  const sessions = await collectRuntimeHostSessionCatalogs([
-    Promise.resolve([guest]),
-    Promise.resolve([owner]),
+  const catalog = await collectRuntimeHostSessionCatalogsWithCoverage([
+    { hostId: 'shared', access: 'session_guest', sessions: Promise.resolve([guest]) },
+    { hostId: 'shared', access: 'owner', sessions: Promise.resolve([owner]) },
   ]);
 
-  assert.deepEqual(sessions, [owner]);
+  assert.deepEqual(catalog.sessions, [owner]);
 });
 
 test('retains a Guest catalog row only while its Runtime Host profile remains known', () => {
@@ -100,11 +104,43 @@ test('retains a Guest catalog row only while its Runtime Host profile remains kn
   );
 });
 
+test('keeps healthy Owner catalogs when Guest mount inventory is unavailable', async () => {
+  const owner = {
+    ...session('owner-session', 3),
+    runtimeHostId: 'owner-host',
+    profileId: 'owner-profile',
+  };
+  const shared = {
+    ...session('shared-session', 2),
+    runtimeHostId: 'guest-host',
+    profileId: 'guest-profile',
+    shared: true as const,
+  };
+
+  assert.deepEqual(
+    await resolveRuntimeHostSessionCatalog(
+      [shared],
+      Promise.resolve({ sessions: [owner], completeHostIds: ['owner-host'] }),
+      () => ['owner-profile'],
+      Promise.reject(new Error('Guest mount store is unreadable')),
+    ),
+    [owner, shared],
+  );
+});
+
 test('fails when every Host catalog rejects', async () => {
   await assert.rejects(
-    collectRuntimeHostSessionCatalogs([
-      Promise.reject(new Error('first unavailable')),
-      Promise.reject(new Error('second unavailable')),
+    collectRuntimeHostSessionCatalogsWithCoverage([
+      {
+        hostId: 'first',
+        access: 'owner',
+        sessions: Promise.reject(new Error('first unavailable')),
+      },
+      {
+        hostId: 'second',
+        access: 'owner',
+        sessions: Promise.reject(new Error('second unavailable')),
+      },
     ]),
     /Every Runtime Host Session Catalog request failed/,
   );
diff --git a/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts
new file mode 100644
index 0000000000..267504c866
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts
@@ -0,0 +1,49 @@
+/*
+ * 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 { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import { collectAvailablePendingTurnRequests } from '../../preload/runtime-host-turn-request-inbox.js';
+
+function request(requestId: string, createdAt: string): SessionTurnAccessRequest {
+  return { requestId, createdAt } as SessionTurnAccessRequest;
+}
+
+test('keeps available collaboration inboxes when another Owner Host rejects', async () => {
+  const requests = await collectAvailablePendingTurnRequests([
+    Promise.reject(new Error('Local Host does not expose collaboration authority')),
+    Promise.resolve([
+      request('later', '2026-09-01T00:00:02.000Z'),
+      request('earlier', '2026-09-01T00:00:01.000Z'),
+    ]),
+  ]);
+
+  assert.deepEqual(requests.map(({ requestId }) => requestId), ['earlier', 'later']);
+});
+
+test('retains the previous inbox projection when every Owner Host rejects', async () => {
+  await assert.rejects(
+    collectAvailablePendingTurnRequests([
+      Promise.reject(new Error('first unavailable')),
+      Promise.reject(new Error('second unavailable')),
+    ]),
+    /Every Runtime Host collaboration inbox request failed/,
+  );
+});
diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
index add0f87ef8..afe90c6f66 100644
--- a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
@@ -92,12 +92,9 @@ export function registerRuntimeHostCollaborationIpc(
     ipcMain,
     'session-collaboration:turn-request:query',
     (_event, sessionId: unknown) =>
-      client.queryCollaborationTurnRequests(requiredId(sessionId, 'Session')),
-  );
-  handleReconnectableRead(
-    ipcMain,
-    'session-collaboration:turn-request:inbox',
-    () => client.queryCollaborationTurnRequests(),
+      client.queryCollaborationTurnRequests(
+        sessionId === undefined ? undefined : requiredId(sessionId, 'Session'),
+      ),
   );
   ipcMain.handle(
     'session-collaboration:turn-request:acknowledge',
diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
index 24dd4feff1..68024201dc 100644
--- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
+++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
@@ -451,6 +451,8 @@ export function registerDesktopGuestSessionMountIpc(
     if (Buffer.byteLength(value, 'utf8') > DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES) {
       throw new Error('Clipboard content is too large to be a shared Session invitation');
     }
+    if (!value) return value;
+    decodeDesktopCollaborationInvitation(value);
     return value;
   });
   return () => {
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index b9a2b43ff4..81c9aa1192 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -1039,7 +1039,6 @@ export interface MakaBridge {
     listWithCoverage(): Promise<{
       sessions: DesktopSessionSummary[];
       completeHostIds: string[];
-      knownProfileIds: string[];
     }>;
     create(input?: CreateSessionRequestInput): Promise<DesktopSessionSummary>;
     send(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 8d0894cb22..81d33886d2 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -122,8 +122,9 @@ import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/
 import type { HealthSnapshot } from '@maka/core/health';
 import {
   collectRuntimeHostSessionCatalogsWithCoverage,
-  reconcileRuntimeHostSessionCatalog,
+  resolveRuntimeHostSessionCatalog,
 } from './runtime-host-session-catalog.js';
+import { collectAvailablePendingTurnRequests } from './runtime-host-turn-request-inbox.js';
 import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import type {
@@ -363,66 +364,30 @@ function recordRuntimeHostIdentity(value: unknown): {
   return { scope, readiness: metadata.readiness };
 }
 
-interface RuntimeHostScopeInventory {
-  readonly readyScopes: readonly DesktopTargetScope[];
-  readonly knownProfileIds: readonly string[];
-}
-
-async function runtimeHostScopeInventory(): Promise<RuntimeHostScopeInventory> {
+async function runtimeHostScopeList(): Promise<readonly DesktopTargetScope[]> {
   while (true) {
     const generation = activeRuntimeHostGeneration;
-    const [identities, guestMounts]: [unknown, unknown] = await Promise.all([
-      ipcRenderer.invoke('runtime-host:identities'),
-      ipcRenderer.invoke('session-collaboration:mount:list'),
-    ]);
+    const identities: unknown = await ipcRenderer.invoke('runtime-host:identities');
     if (generation !== activeRuntimeHostGeneration) continue;
     if (!Array.isArray(identities)) {
       throw new Error('Desktop Runtime Host identities are unavailable');
     }
-    if (!Array.isArray(guestMounts)) {
-      throw new Error('Desktop shared Session mounts are unavailable');
-    }
     const authoritativeScopeKeys = new Set<RuntimeHostScopeKey>();
     const readyScopes: DesktopTargetScope[] = [];
-    const knownProfileIds = new Set(
-      guestMounts.map((mount) => {
-        if (
-          !mount ||
-          typeof mount !== 'object' ||
-          !('mountId' in mount) ||
-          typeof mount.mountId !== 'string'
-        ) {
-          throw new Error('Desktop shared Session mount is invalid');
-        }
-        return mount.mountId;
-      }),
-    );
     for (const identity of identities) {
       const { scope, readiness } = recordRuntimeHostIdentity(identity);
       authoritativeScopeKeys.add(runtimeHostScopeKey(scope));
-      const metadata = runtimeHostMetadataFor(scope);
-      if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable');
-      knownProfileIds.add(metadata.profileId);
-      if (readiness === 'ready') {
-        readyScopes.push(scope);
-      }
+      if (readiness === 'ready') readyScopes.push(scope);
     }
     for (const scopeKey of runtimeHostScopes.keys()) {
       if (authoritativeScopeKeys.has(scopeKey)) continue;
       runtimeHostScopes.delete(scopeKey);
       runtimeHostMetadata.delete(scopeKey);
     }
-    return {
-      readyScopes,
-      knownProfileIds: [...knownProfileIds],
-    };
+    return readyScopes;
   }
 }
 
-async function runtimeHostScopeList(): Promise<readonly DesktopTargetScope[]> {
-  return (await runtimeHostScopeInventory()).readyScopes;
-}
-
 async function runtimeHostSessionRef(sessionId: string): Promise<{
   readonly scope: DesktopTargetScope;
   readonly sessionId: string;
@@ -933,10 +898,13 @@ async function listDesktopSessions(
     ) as SessionCatalogSummary[];
     return sessions.map((session) => projectSessionSummary(parent.scope, session));
   }
-  const snapshot = await listDesktopSessionsWithCoverage();
-  lastDesktopSessionCatalog = reconcileRuntimeHostSessionCatalog(
+  lastDesktopSessionCatalog = await resolveRuntimeHostSessionCatalog(
     lastDesktopSessionCatalog,
-    snapshot,
+    listDesktopSessionsWithCoverage(),
+    () => [...runtimeHostMetadata.values()].map(({ profileId }) => profileId),
+    // Unknown Guest coverage cannot suppress healthy Owner catalogs or prove
+    // that a previously observed Guest mount was removed.
+    listKnownGuestMountProfileIds(),
   );
   return lastDesktopSessionCatalog;
 }
@@ -944,11 +912,10 @@ async function listDesktopSessions(
 async function listDesktopSessionsWithCoverage(): Promise<{
   sessions: DesktopSessionSummary[];
   completeHostIds: string[];
-  knownProfileIds: string[];
 }> {
-  const inventory = await runtimeHostScopeInventory();
+  const scopes = await runtimeHostScopeList();
   const catalog = await collectRuntimeHostSessionCatalogsWithCoverage(
-    inventory.readyScopes.map((scope) => {
+    scopes.map((scope) => {
       const metadata = runtimeHostMetadataFor(scope);
       if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable');
       return {
@@ -961,10 +928,25 @@ async function listDesktopSessionsWithCoverage(): Promise<{
     }),
   );
   recordSessionCatalogScopes(catalog.sessions);
-  return {
-    ...catalog,
-    knownProfileIds: [...inventory.knownProfileIds],
-  };
+  return catalog;
+}
+
+async function listKnownGuestMountProfileIds(): Promise<string[]> {
+  const mounts: unknown = await ipcRenderer.invoke('session-collaboration:mount:list');
+  if (!Array.isArray(mounts)) {
+    throw new Error('Desktop shared Session mounts are unavailable');
+  }
+  return mounts.map((mount) => {
+    if (
+      !mount ||
+      typeof mount !== 'object' ||
+      !('mountId' in mount) ||
+      typeof mount.mountId !== 'string'
+    ) {
+      throw new Error('Desktop shared Session mount is invalid');
+    }
+    return mount.mountId;
+  });
 }
 
 async function createDesktopSessionOnScope(
@@ -1374,10 +1356,10 @@ const makaBridge = {
       const scopes = (await runtimeHostScopeList()).filter(
         (scope) => runtimeHostMetadataFor(scope)?.profileAccess === 'owner',
       );
-      const results = await Promise.all(
+      return collectAvailablePendingTurnRequests(
         scopes.map(async (scope) => {
           const result = await ipcRenderer.invoke(
-            'session-collaboration:turn-request:inbox',
+            'session-collaboration:turn-request:query',
             scope,
           ) as CollaborationTurnRequestQueryResult;
           return result.requests
@@ -1391,9 +1373,6 @@ const makaBridge = {
             }));
         }),
       );
-      return results
-        .flat()
-        .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
     },
     async acknowledgeTurnRequest(sessionId, requestId) {
       const session = await runtimeHostSessionRef(sessionId);
diff --git a/apps/desktop/src/preload/runtime-host-session-catalog.ts b/apps/desktop/src/preload/runtime-host-session-catalog.ts
index 8a274b3893..d8c5955d36 100644
--- a/apps/desktop/src/preload/runtime-host-session-catalog.ts
+++ b/apps/desktop/src/preload/runtime-host-session-catalog.ts
@@ -35,6 +35,24 @@ export interface RuntimeHostSessionCatalogSnapshot extends RuntimeHostSessionCat
   readonly knownProfileIds: string[];
 }
 
+export async function resolveRuntimeHostSessionCatalog(
+  current: readonly DesktopSessionSummary[],
+  coverage: Promise<RuntimeHostSessionCatalogCoverage>,
+  knownRuntimeProfileIds: () => readonly string[],
+  guestMountProfileIds: Promise<readonly string[]>,
+): Promise<DesktopSessionSummary[]> {
+  const [snapshot, knownGuestProfileIds] = await Promise.all([
+    coverage,
+    guestMountProfileIds.catch(() =>
+      current.flatMap((session) => session.shared === true ? [session.profileId] : []),
+    ),
+  ]);
+  return reconcileRuntimeHostSessionCatalog(current, {
+    ...snapshot,
+    knownProfileIds: [...knownRuntimeProfileIds(), ...knownGuestProfileIds],
+  });
+}
+
 export async function collectRuntimeHostSessionCatalogsWithCoverage(
   requests: readonly RuntimeHostSessionCatalogRequest[],
 ): Promise<RuntimeHostSessionCatalogCoverage> {
@@ -64,20 +82,6 @@ export async function collectRuntimeHostSessionCatalogsWithCoverage(
   };
 }
 
-export async function collectRuntimeHostSessionCatalogs(
-  requests: readonly Promise<DesktopSessionSummary[]>[],
-): Promise<DesktopSessionSummary[]> {
-  const results = await Promise.allSettled(requests);
-  const groups = results.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
-  if (requests.length > 0 && groups.length === 0) {
-    throw new AggregateError(
-      results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []),
-      'Every Runtime Host Session Catalog request failed',
-    );
-  }
-  return sortSessionCatalogs(groups.flat());
-}
-
 /**
  * Commits complete Host catalogs authoritatively while retaining the last
  * accepted rows for a Host that still exists but cannot answer this read.
diff --git a/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts
new file mode 100644
index 0000000000..86199b9187
--- /dev/null
+++ b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts
@@ -0,0 +1,38 @@
+/*
+ * 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 type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+
+export async function collectAvailablePendingTurnRequests(
+  queries: readonly Promise<readonly SessionTurnAccessRequest[]>[],
+): Promise<SessionTurnAccessRequest[]> {
+  const results = await Promise.allSettled(queries);
+  const available = results.flatMap(
+    (result) => result.status === 'fulfilled' ? [result.value] : [],
+  );
+  if (queries.length > 0 && available.length === 0) {
+    throw new AggregateError(
+      results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []),
+      'Every Runtime Host collaboration inbox request failed',
+    );
+  }
+  return available
+    .flat()
+    .sort((left, right) => left.createdAt.localeCompare(right.createdAt));
+}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 100f14385c..acc05177ce 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -109,12 +109,7 @@ import {
 import { useNewTaskChoice } from './use-new-task-choice';
 import { SessionCollaborationDialog } from './session-collaboration-dialog';
 import { SessionTurnRequestComposer } from './session-turn-request-composer.js';
-import {
-  SessionTurnRequestApprovalForSession,
-  SessionTurnRequestBadge,
-  SessionTurnRequestInboxProvider,
-  useSessionCollaborationDialog,
-} from './features/session-collaboration';
+import * as SessionCollaboration from './features/session-collaboration';
 import { getSessionCollaborationCopy } from './locales/session-collaboration-copy';
 import { NEW_TASK_PENDING_KEY } from './pending-items';
 import { parseDesktopSlashCommand } from './desktop-slash-command';
@@ -247,6 +242,7 @@ type ComposerImportOwner = {
  */
 const SETTLE_FALLBACK_GRACE_MS = 1000;
 const FIRST_SEND_OBSERVATION_TIMEOUT_MS = 30_000;
+const { useSessionCollaborationDialog } = SessionCollaboration;
 type FirstSendObservationWaiter = {
   promise: Promise<void>;
   resolve: () => void;
@@ -2731,7 +2727,7 @@ function AppShellContent({
       reportError={showSessionError}
     >
     <ComposerMentionsProvider {...composerMentionsSurface}>
-    <SessionTurnRequestInboxProvider
+    <SessionCollaboration.SessionTurnRequestInboxProvider
       sessions={sessions}
       toast={toastApi}
       onOpenSession={openSession}
@@ -2865,7 +2861,7 @@ function AppShellContent({
             projects={localProjects}
             streamingSessionIds={streamingSessionIds}
             staleSessionIds={staleSessionIds}
-            SessionBadge={SessionTurnRequestBadge}
+            SessionBadge={SessionCollaboration.SessionTurnRequestBadge}
             ports={sessionNavigationPorts}
             commandsRef={sessionNavigationCommandsRef}
             onExitWorkHub={exitWorkHub}
@@ -2935,7 +2931,9 @@ function AppShellContent({
                 composer={
                   <>
                     {!sharedSessionActive && activeId ? (
-                      <SessionTurnRequestApprovalForSession sessionId={activeId} />
+                      <SessionCollaboration.SessionTurnRequestApprovalForSession
+                        sessionId={activeId}
+                      />
                     ) : null}
                     {!sharedSessionActive && navSelection.section === 'sessions' &&
                     activeId &&
@@ -3336,7 +3334,7 @@ function AppShellContent({
         onSelectedRuntimeHostProfileIdChange={setSettingsProfileId}
       />
     </div>
-    </SessionTurnRequestInboxProvider>
+    </SessionCollaboration.SessionTurnRequestInboxProvider>
     </ComposerMentionsProvider>
     </Goals.GoalProvider>
   );
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
index 001d206b22..21b53b6441 100644
--- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -59,7 +59,7 @@ const ZH = {
     code: '邀请码',
     pasteInvitation: '粘贴邀请码',
     clipboardEmpty: '剪贴板中没有可粘贴的邀请码。',
-    clipboardUnavailable: '无法读取剪贴板;请手动粘贴邀请码。',
+    clipboardUnavailable: '剪贴板中没有有效的邀请码;请手动粘贴。',
     join: '加入',
     validatingInvitation: '正在验证邀请码…',
     discoveringHost: '正在查找任务所在的 Runtime Host…',
@@ -151,7 +151,7 @@ const EN = {
     code: 'Invitation code',
     pasteInvitation: 'Paste invitation',
     clipboardEmpty: 'There is no invitation to paste from the clipboard.',
-    clipboardUnavailable: 'Could not read the clipboard. Paste the invitation manually.',
+    clipboardUnavailable: 'The clipboard does not contain a valid invitation. Paste it manually.',
     join: 'Join',
     validatingInvitation: 'Validating the invitation…',
     discoveringHost: 'Finding the Runtime Host for this task…',
diff --git a/native/runtime-host-peer/src/engine.rs b/native/runtime-host-peer/src/engine.rs
index dd171c035d..7f650253f6 100644
--- a/native/runtime-host-peer/src/engine.rs
+++ b/native/runtime-host-peer/src/engine.rs
@@ -82,6 +82,7 @@ const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(10);
 const TARGET_COORDINATION_RESERVATIONS: usize = 2;
 const MAX_AUTOMATIC_RELAY_CANDIDATES: usize = 8;
 const MAX_RELAY_ADDRESSES_PER_PEER: usize = 4;
+const MAX_PUBLISHED_COORDINATION_RELAY_ADDRESSES: usize = 16;
 const TRANSIT_FALLBACK_DELAY: Duration = Duration::from_secs(3);
 const MAX_TRANSIT_RESERVATIONS: usize = 32;
 const MAX_TRANSIT_CIRCUITS: usize = 8;
@@ -2551,13 +2552,38 @@ fn publish_active_coordination_relays(
     relays: &HashMap<PeerId, CoordinationRelay>,
     snapshot: &Arc<RwLock<Vec<Multiaddr>>>,
 ) {
-    let mut addresses = relays
-        .values()
-        .filter(|relay| relay.reserve && relay.reservation_accepted)
-        .flat_map(|relay| relay.reservation_addresses.iter().cloned())
+    let mut relay_routes = relays
+        .iter()
+        .filter(|(_, relay)| relay.reserve && relay.reservation_accepted)
+        .map(|(peer_id, relay)| {
+            let mut routes = relay.reservation_addresses.clone();
+            routes.sort_unstable_by_key(ToString::to_string);
+            routes.dedup();
+            (peer_id.to_string(), routes)
+        })
         .collect::<Vec<_>>();
-    addresses.sort_unstable_by_key(ToString::to_string);
-    addresses.dedup();
+    relay_routes.sort_unstable_by(|left, right| left.0.cmp(&right.0));
+
+    let mut addresses = Vec::new();
+    let mut seen = HashSet::new();
+    let route_count = relay_routes
+        .iter()
+        .map(|(_, routes)| routes.len())
+        .max()
+        .unwrap_or_default();
+    'routes: for route_index in 0..route_count {
+        for (_, routes) in &relay_routes {
+            let Some(address) = routes.get(route_index) else {
+                continue;
+            };
+            if seen.insert(address.clone()) {
+                addresses.push(address.clone());
+                if addresses.len() == MAX_PUBLISHED_COORDINATION_RELAY_ADDRESSES {
+                    break 'routes;
+                }
+            }
+        }
+    }
     if let Ok(mut current) = snapshot.write() {
         *current = addresses;
     }
@@ -3931,6 +3957,49 @@ mod tests {
         );
     }
 
+    #[test]
+    fn active_coordination_routes_respect_the_shared_route_limit() {
+        let relays = (0..5)
+            .map(|relay_index| {
+                let peer_id = PeerId::random();
+                let reservation_addresses = (0..MAX_RELAY_ADDRESSES_PER_PEER)
+                    .map(|route_index| {
+                        format!(
+                            "/ip4/192.0.2.{}/tcp/{}/p2p/{peer_id}",
+                            relay_index + 1,
+                            4001 + route_index,
+                        )
+                        .parse()
+                        .expect("valid relay address")
+                    })
+                    .collect();
+                (
+                    peer_id,
+                    CoordinationRelay {
+                        reserve: true,
+                        reservation_accepted: true,
+                        reservation_addresses,
+                        ..CoordinationRelay::default()
+                    },
+                )
+            })
+            .collect::<HashMap<_, _>>();
+        let snapshot = Arc::new(RwLock::new(Vec::new()));
+
+        publish_active_coordination_relays(&relays, &snapshot);
+
+        let routes = snapshot.read().expect("read snapshot");
+        assert_eq!(routes.len(), MAX_PUBLISHED_COORDINATION_RELAY_ADDRESSES);
+        assert_eq!(
+            routes
+                .iter()
+                .map(|route| coordination_relay_peer_id(route).expect("published relay identity"))
+                .collect::<HashSet<_>>()
+                .len(),
+            relays.len(),
+        );
+    }
+
     #[test]
     fn coordination_relay_requires_one_terminal_peer_identity() {
         let relay = PeerId::random();
diff --git a/packages/runtime-host/src/__tests__/execution-service-listeners.test.ts b/packages/runtime-host/src/__tests__/execution-service-listeners.test.ts
index 84a597b727..8027a0aab7 100644
--- a/packages/runtime-host/src/__tests__/execution-service-listeners.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-service-listeners.test.ts
@@ -27,19 +27,18 @@ test('the execution service exposes relay reservations discovered after startup'
   let coordinationRelays: readonly string[] = [];
   let listenersClosed = false;
   let ownerClosed = false;
+  const peerListener = {
+    peerId: '12D3KooWpeer',
+    listenAddresses: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+    get coordinationRelays() {
+      return coordinationRelays;
+    },
+  };
   const listeners: RuntimeHostListenerSet = {
     listeners: [],
     localEndpoint: 'local',
     websocketEndpoints: [],
-    get peerListeners() {
-      return [
-        {
-          peerId: '12D3KooWpeer',
-          listenAddresses: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
-          coordinationRelays,
-        },
-      ];
-    },
+    peerListeners: [peerListener],
     async closeAdmission() {},
     async cleanup() {
       listenersClosed = true;
diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts
index 0cfb70336c..30cf3924bc 100644
--- a/packages/runtime-host/src/__tests__/peer-native.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-native.test.ts
@@ -51,6 +51,7 @@ let finishMeshAccept;
 const pending = new Map();
 const stats = { starts: 0, closes: 0, requests: [], cancellations: [] };
 let missFirstCancellation = true;
+let selfContainedRoutesPrepared = false;
 const stream = { read: async () => null, write: async () => {}, close: async () => {}, abort: () => {} };
 module.exports = {
   stats,
@@ -72,12 +73,15 @@ module.exports = {
       connect: ({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }) => {
         stats.requests.push({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds });
         if (peerId === 'unreachable') return Promise.reject(Object.assign(new Error('transit_unavailable: no approved route'), { code: 'GenericFailure' }));
-        if (peerId === 'ready' || peerId === 'fallback' || peerId === 'self-contained') return Promise.resolve(stream);
+        if (peerId === 'ready' || peerId === 'fallback' || (peerId === 'self-contained' && selfContainedRoutesPrepared)) return Promise.resolve(stream);
         return new Promise((resolve, reject) => pending.set(requestId, { resolve, reject }));
       },
       connectMeshControl: ({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }) => {
         stats.requests.push({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds });
-        if (peerId === 'ready') return Promise.resolve(stream);
+        if (peerId === 'ready' || peerId === 'self-contained') {
+          if (peerId === 'self-contained') selfContainedRoutesPrepared = true;
+          return Promise.resolve(stream);
+        }
         return new Promise((resolve, reject) => pending.set(requestId, { resolve, reject }));
       },
       configureTransit: async () => {},
@@ -101,11 +105,8 @@ module.exports = {
     );
     let routesPrepared = false;
     const preparedPeerIds: string[] = [];
-    let releaseSelfContainedPreparation!: () => void;
-    const selfContainedPreparation = new Promise<void>((resolve) => {
-      releaseSelfContainedPreparation = resolve;
-    });
-    const client = createRuntimeHostPeerClient({
+    let client!: ReturnType<typeof createRuntimeHostPeerClient>;
+    client = createRuntimeHostPeerClient({
       nativePath,
       keyPath: join(directory, 'peer.key'),
       routeResolver: {
@@ -113,7 +114,9 @@ module.exports = {
           preparedPeerIds.push(peerId);
           routesPrepared = true;
           if (peerId === 'fallback') throw new Error('Mesh refresh failed');
-          if (peerId === 'self-contained') await selfContainedPreparation;
+          if (peerId === 'self-contained') {
+            await client.connectMeshControl(peerConnectInput(peerId));
+          }
         },
         resolveRoutes: () =>
           routesPrepared
@@ -162,10 +165,8 @@ module.exports = {
       ...peerConnectInput('self-contained'),
       coordinationRelays: ['/memory/explicit-relay'],
     });
-    await waitForRequestCount(native.default.stats, 7);
-    assert.equal(preparedPeerIds.includes('self-contained'), true);
-    releaseSelfContainedPreparation();
     await selfContained;
+    assert.equal(preparedPeerIds.includes('self-contained'), true);
     assert.deepEqual(native.default.stats, {
       starts: 1,
       closes: 0,
@@ -215,6 +216,13 @@ module.exports = {
         {
           requestId: 7,
           peerId: 'self-contained',
+          routeHints: ['/memory/1'],
+          coordinationRelays: [],
+          transitRelayPeerIds: [],
+        },
+        {
+          requestId: 8,
+          peerId: 'self-contained',
           routeHints: ['/memory/discovered', '/memory/1'],
           coordinationRelays: ['/memory/relay', '/memory/explicit-relay'],
           transitRelayPeerIds: ['transit-peer'],
diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts
index 7ef861f576..42161db3c8 100644
--- a/packages/runtime-host/src/client/connection.ts
+++ b/packages/runtime-host/src/client/connection.ts
@@ -493,7 +493,6 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
         ...(isDomainRequest ? { domainState: 'queued' as const } : {}),
         timer,
       });
-      this.#scheduleLivenessCheck();
     });
     const frame = {
       requestId,
@@ -695,7 +694,6 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
       if (retired?.operation === frame.operation) {
         this.#retiredRequests.delete(frame.requestId);
         this.#releaseDomainSlot(retired);
-        this.#scheduleLivenessCheck();
         return;
       }
       this.#fail(new Error('Runtime Host returned an unmatched operation response'));
@@ -707,7 +705,6 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
     }
     this.#pendingRequests.delete(frame.requestId);
     if (pending.timer) clearTimeout(pending.timer);
-    this.#scheduleLivenessCheck();
     if (frame.ok) {
       try {
         const accepted = pending.accept(frame.result);
@@ -776,7 +773,6 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
       pending.reject(
         interruptedRequestError(pending.operation, 'not_dispatched', 'timeout', error),
       );
-      this.#scheduleLivenessCheck();
       return;
     }
     this.#retiredRequests.set(requestId, {
@@ -784,7 +780,6 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
       ...(pending.domainState === 'in_flight' ? { domainState: pending.domainState } : {}),
     });
     pending.reject(interruptedRequestError(pending.operation, 'dispatched', 'timeout', error));
-    this.#scheduleLivenessCheck();
   }
 
   #releaseDomainSlot(request: PendingRequest | RetiredRequest): void {
diff --git a/packages/runtime-host/src/client/peer-client.ts b/packages/runtime-host/src/client/peer-client.ts
index 5e3d86abe9..543efdb2f0 100644
--- a/packages/runtime-host/src/client/peer-client.ts
+++ b/packages/runtime-host/src/client/peer-client.ts
@@ -216,16 +216,6 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     onPhase?: (phase: RuntimeHostPeerConnectionPhase) => void,
   ): Promise<RuntimeHostPeerNativeStream> {
     notifyPhase(onPhase, 'discovering');
-    if (input.routeHints.length > 0 && (input.coordinationRelays?.length ?? 0) > 0) {
-      // Reserve the application-connect lane before refreshing Mesh control.
-      // A fresh invitation therefore dials immediately, while a persisted
-      // invitation still refreshes routes for the next reconnect if its
-      // advertised reservation has rotated.
-      notifyPhase(onPhase, 'connecting');
-      const connection = this.#connect(input, signal, 'application');
-      void this.#prepareRoutes(input, signal).catch(() => undefined);
-      return connection;
-    }
     await this.#prepareRoutes(input, signal);
     notifyPhase(onPhase, 'connecting');
     return this.#connect(input, signal, 'application');
diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts
index 2ed15c3f80..b2ee32967e 100644
--- a/packages/runtime-host/src/server/execution-service.ts
+++ b/packages/runtime-host/src/server/execution-service.ts
@@ -168,13 +168,7 @@ export function attachPeerOwnerCleanup(
   owner: RuntimeHostPeerMeshOwner,
 ): RuntimeHostListenerSet {
   return {
-    listeners: listeners.listeners,
-    localEndpoint: listeners.localEndpoint,
-    websocketEndpoints: listeners.websocketEndpoints,
-    get peerListeners() {
-      return listeners.peerListeners;
-    },
-    closeAdmission: () => listeners.closeAdmission(),
+    ...listeners,
     cleanup: async () => {
       const errors: unknown[] = [];
       await listeners.cleanup().catch((error: unknown) => errors.push(error));
diff --git a/packages/runtime-host/src/server/listener-set.ts b/packages/runtime-host/src/server/listener-set.ts
index cdc48047e5..87f8353b11 100644
--- a/packages/runtime-host/src/server/listener-set.ts
+++ b/packages/runtime-host/src/server/listener-set.ts
@@ -129,6 +129,17 @@ export function createRuntimeHostListenerSet(
   additional: readonly RuntimeHostListener[] = [],
 ): RuntimeHostListenerSet {
   const listeners = Object.freeze([local, ...additional]);
+  const peerListeners = Object.freeze(
+    additional.filter(isRuntimeHostPeerListener).map((listener) =>
+      Object.freeze({
+        peerId: listener.peerId,
+        listenAddresses: Object.freeze([...listener.listenAddresses]),
+        get coordinationRelays() {
+          return Object.freeze([...listener.coordinationRelays]);
+        },
+      }),
+    ),
+  );
   return {
     listeners,
     localEndpoint: local.endpoint,
@@ -137,17 +148,7 @@ export function createRuntimeHostListenerSet(
         .filter((listener) => listener.kind === 'websocket')
         .map((listener) => listener.endpoint),
     ),
-    get peerListeners() {
-      return Object.freeze(
-        additional.filter(isRuntimeHostPeerListener).map((listener) =>
-          Object.freeze({
-            peerId: listener.peerId,
-            listenAddresses: Object.freeze([...listener.listenAddresses]),
-            coordinationRelays: Object.freeze([...listener.coordinationRelays]),
-          }),
-        ),
-      );
-    },
+    peerListeners,
     closeAdmission: () => settleListeners(listeners, (listener) => listener.closeAdmission()),
     cleanup: () => settleListeners([...listeners].reverse(), (listener) => listener.cleanup()),
   };


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


(maka) 09/11: fix(desktop): compact turn approval prompt

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit ff574d8b8a21b05cfcde313eb8836afc7f718f8e
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 20:49:18 2026 +0800

    fix(desktop): compact turn approval prompt
---
 .../ui/session-turn-request-approval.tsx           | 21 +++++++++++++++-
 apps/desktop/src/renderer/styles/shell-layout.css  | 29 ++++++++++++++++++++++
 2 files changed, 49 insertions(+), 1 deletion(-)

diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
index da35f7a276..5af9737403 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
@@ -18,6 +18,7 @@
  */
 
 import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import { HoverCard } from '@astryxdesign/core/HoverCard';
 import { Banner, Button } from '@maka/ui';
 import {
   useSessionTurnRequestInboxContext,
@@ -55,10 +56,28 @@ export function SessionTurnRequestApproval(props: {
   return (
     <div className="sessionTurnRequestApproval">
       <Banner
+        className="sessionTurnRequestApprovalBanner"
         status="warning"
         role="status"
         title={copy.ownerTurnRequestTitle}
-        description={request.intent.content.text}
+        description={(
+          <HoverCard
+            content={(
+              <div className="sessionTurnRequestApprovalDetails">
+                {request.intent.content.text}
+              </div>
+            )}
+            label={copy.ownerTurnRequestTitle}
+            placement="above"
+            alignment="start"
+            focusTrigger="always"
+            hasHoverIndication={false}
+          >
+            <span className="sessionTurnRequestApprovalIntent" tabIndex={0}>
+              {request.intent.content.text}
+            </span>
+          </HoverCard>
+        )}
         endContent={(
           <div className="sessionTurnRequestApprovalActions">
             <Button
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css
index 9ab0e91944..11b404d2db 100644
--- a/apps/desktop/src/renderer/styles/shell-layout.css
+++ b/apps/desktop/src/renderer/styles/shell-layout.css
@@ -590,10 +590,39 @@ dialog:modal {
 .sessionTurnRequestApproval {
   display: grid;
   gap: var(--space-1);
+  width: 100%;
+  max-width: 36rem;
+  margin-inline: auto;
   padding: var(--space-2) var(--space-3) 0;
+  box-sizing: border-box;
   background: var(--background);
 }
 
+.sessionTurnRequestApprovalBanner {
+  min-width: 0;
+}
+
+.sessionTurnRequestApprovalIntent {
+  display: block;
+  width: 100%;
+  max-width: 100%;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  cursor: help;
+}
+
+.sessionTurnRequestApprovalDetails {
+  width: min(32rem, calc(100vw - 2rem));
+  max-height: min(16rem, 50vh);
+  overflow: auto;
+  white-space: pre-wrap;
+  overscroll-behavior: contain;
+  scrollbar-gutter: stable;
+  user-select: text;
+}
+
 .sessionTurnRequestApprovalActions {
   display: inline-flex;
   align-items: center;


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


(maka) 06/11: fix(desktop): streamline collaboration invitation transfer

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 14b12f92c4190447a9f221eaa3f89af954dda69d
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 18:57:49 2026 +0800

    fix(desktop): streamline collaboration invitation transfer
---
 .../runtime-host-guest-session-mounts.test.ts      | 40 ++++++++++++++++++++
 apps/desktop/src/main/runtime-host-boot.ts         |  6 ++-
 .../main/runtime-host-collaboration-invitation.ts  |  7 +++-
 .../src/main/runtime-host-guest-session-mounts.ts  | 14 ++++++-
 apps/desktop/src/preload/bridge-contract.d.ts      |  2 +
 apps/desktop/src/preload/preload.ts                |  3 ++
 .../features/session-collaboration/ports.ts        |  1 +
 .../ui/session-collaboration-join-dialog.tsx       | 44 ++++++++++++++++++++--
 .../renderer/locales/session-collaboration-copy.ts |  6 +++
 .../create-session-collaboration-services.ts       |  1 +
 .../src/renderer/session-collaboration-dialog.tsx  | 20 +++++++++-
 .../src/renderer/styles/settings/runtime-host.css  |  5 +++
 12 files changed, 140 insertions(+), 9 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
index 3d8102b844..7f86dd6f72 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
@@ -25,6 +25,7 @@ import {
   createDesktopGuestSessionMountService,
   type GuestSessionMount,
   type GuestSessionMountStore,
+  registerDesktopGuestSessionMountIpc,
 } from '../runtime-host-guest-session-mounts.js';
 import { RuntimeHostPairingFinalizationInterruptedError } from '../runtime-host-desktop-manager.js';
 
@@ -273,6 +274,45 @@ test('cancels an in-flight import and removes its durable mount desire', async (
   await mounts.close();
 });
 
+test('reads an invitation from the clipboard only on explicit IPC invocation', async () => {
+  type IpcHandler = Parameters<Pick<Electron.IpcMain, 'handle'>['handle']>[1];
+  const handlers = new Map<string, IpcHandler>();
+  let clipboardReads = 0;
+  let clipboardText = '  invitation-code  ';
+  const mounts = service(memoryStore());
+  const dispose = registerDesktopGuestSessionMountIpc(
+    {
+      handle(channel, handler) {
+        handlers.set(channel, handler);
+      },
+      removeHandler(channel) {
+        handlers.delete(channel);
+      },
+    },
+    mounts,
+    () => {
+      clipboardReads += 1;
+      return clipboardText;
+    },
+  );
+
+  assert.equal(clipboardReads, 0);
+  const handler = handlers.get('session-collaboration:invitation:read-clipboard');
+  assert.ok(handler);
+  assert.equal(await handler({} as never), 'invitation-code');
+  assert.equal(clipboardReads, 1);
+
+  clipboardText = 'x'.repeat(32 * 1024 + 1);
+  assert.throws(
+    () => handler({} as never),
+    /Clipboard content is too large to be a shared Session invitation/,
+  );
+
+  dispose();
+  assert.equal(handlers.size, 0);
+  await mounts.close();
+});
+
 function service(
   store: GuestSessionMountStore,
   overrides: {
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index f4dc7cd64d..893f54e62f 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -1597,7 +1597,11 @@ function registerPersistentClientIpc(): void {
   });
   registerMarkdownSaveIpc({ ipcMain, mainWindowController });
   registerDesktopRuntimeHostProfileIpc(ipcMain, runtimeHostProfileService);
-  registerDesktopGuestSessionMountIpc(ipcMain, guestSessionMountService);
+  registerDesktopGuestSessionMountIpc(
+    ipcMain,
+    guestSessionMountService,
+    () => clipboard.readText(),
+  );
   registerClientSettingsIpc({
     ipcMain,
     settingsStore,
diff --git a/apps/desktop/src/main/runtime-host-collaboration-invitation.ts b/apps/desktop/src/main/runtime-host-collaboration-invitation.ts
index 7b9890c4fc..3137dc2d74 100644
--- a/apps/desktop/src/main/runtime-host-collaboration-invitation.ts
+++ b/apps/desktop/src/main/runtime-host-collaboration-invitation.ts
@@ -24,7 +24,7 @@ import {
 import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol';
 
 const SCHEMA_VERSION = 1;
-const CODE_MAX_BYTES = 32 * 1024;
+export const DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES = 32 * 1024;
 
 export interface DesktopCollaborationConnectionTarget {
   readonly name: string;
@@ -54,7 +54,10 @@ export function encodeDesktopCollaborationInvitation(
 export function decodeDesktopCollaborationInvitation(
   code: string,
 ): DesktopCollaborationInvitation {
-  if (!code || Buffer.byteLength(code, 'utf8') > CODE_MAX_BYTES) {
+  if (
+    !code ||
+    Buffer.byteLength(code, 'utf8') > DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES
+  ) {
     throw new Error('Invalid Desktop collaboration invitation');
   }
   let value: unknown;
diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
index 0b890f4872..24dd4feff1 100644
--- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
+++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
@@ -33,7 +33,10 @@ import type {
   SessionCollaborationImportResult,
   SessionCollaborationMountSummary,
 } from '../shared/session-collaboration.js';
-import { decodeDesktopCollaborationInvitation } from './runtime-host-collaboration-invitation.js';
+import {
+  decodeDesktopCollaborationInvitation,
+  DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES,
+} from './runtime-host-collaboration-invitation.js';
 import { RuntimeHostPairingFinalizationInterruptedError } from './runtime-host-desktop-manager.js';
 
 const STORE_SCHEMA_VERSION = 1;
@@ -419,12 +422,14 @@ export function createDesktopGuestSessionMountService(input: {
 export function registerDesktopGuestSessionMountIpc(
   ipcMain: Pick<Electron.IpcMain, 'handle' | 'removeHandler'>,
   service: DesktopGuestSessionMountService,
+  readClipboardText: () => string,
 ): () => void {
   const channels = [
     'session-collaboration:import',
     'session-collaboration:import:cancel',
     'session-collaboration:mount:list',
     'session-collaboration:mount:remove',
+    'session-collaboration:invitation:read-clipboard',
   ] as const;
   ipcMain.handle(
     channels[0],
@@ -441,6 +446,13 @@ export function registerDesktopGuestSessionMountIpc(
     service.cancelImport(requireOperationId(operationIdValue)));
   ipcMain.handle(channels[2], () => service.list());
   ipcMain.handle(channels[3], (_event, mountId: string) => service.remove(mountId));
+  ipcMain.handle(channels[4], () => {
+    const value = readClipboardText().trim();
+    if (Buffer.byteLength(value, 'utf8') > DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES) {
+      throw new Error('Clipboard content is too large to be a shared Session invitation');
+    }
+    return value;
+  });
   return () => {
     for (const channel of channels) ipcMain.removeHandler(channel);
   };
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 609c881e0c..b9a2b43ff4 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -748,6 +748,8 @@ export interface MakaBridge {
       readonly operationId: string;
     }, onProgress?: (phase: DesktopSessionCollaborationImportPhase) => void): Promise<DesktopSessionCollaborationImportResult>;
     cancelImport(operationId: string): Promise<DesktopSessionCollaborationCancelResult>;
+    /** Reads only after the user invokes the invitation paste action. */
+    readInvitationClipboard(): Promise<string>;
     listMounts(): Promise<readonly DesktopGuestSessionMountSummary[]>;
     removeMount(mountId: string): Promise<void>;
     requestTurn(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 466c15f8a7..8d0894cb22 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -1341,6 +1341,9 @@ const makaBridge = {
     cancelImport(operationId) {
       return ipcRenderer.invoke('session-collaboration:import:cancel', operationId);
     },
+    readInvitationClipboard() {
+      return ipcRenderer.invoke('session-collaboration:invitation:read-clipboard');
+    },
     listMounts() {
       return ipcRenderer.invoke('session-collaboration:mount:list');
     },
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ports.ts b/apps/desktop/src/renderer/features/session-collaboration/ports.ts
index c8c045f679..7e65b3a5ac 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/ports.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/ports.ts
@@ -42,6 +42,7 @@ export interface SessionCollaborationServices {
     readonly operationId: string;
   }, onProgress?: (phase: SessionCollaborationImportPhase) => void): Promise<SessionCollaborationImportResult>;
   cancelImport(operationId: string): Promise<SessionCollaborationCancelResult>;
+  readInvitationClipboard(): Promise<string>;
   listMounts(): Promise<readonly SessionCollaborationMountSummary[]>;
   removeMount(mountId: string): Promise<void>;
   getPendingTurnRequests(): Promise<readonly SessionTurnAccessRequest[]>;
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx
index 414158f9ed..d047c09b2a 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx
@@ -45,6 +45,9 @@ export interface SessionCollaborationJoinCopy {
   readonly invalidCode: string;
   readonly directPathUnavailable: string;
   readonly code: string;
+  readonly pasteInvitation: string;
+  readonly clipboardEmpty: string;
+  readonly clipboardUnavailable: string;
   readonly join: string;
   readonly validatingInvitation: string;
   readonly discoveringHost: string;
@@ -66,6 +69,7 @@ export function SessionCollaborationJoinDialog(props: {
   const services = useSessionCollaborationServices();
   const toast = useToast();
   const [code, setCode] = useState('');
+  const [readingClipboard, setReadingClipboard] = useState(false);
   const [mounts, setMounts] = useState<readonly SessionCollaborationMountSummary[]>([]);
   const [removingMountId, setRemovingMountId] = useState<string>();
   const activeOperationId = useRef<string | undefined>(undefined);
@@ -198,6 +202,27 @@ export function SessionCollaborationJoinDialog(props: {
     }
   }
 
+  async function pasteInvitation(): Promise<void> {
+    if (working || readingClipboard) return;
+    setReadingClipboard(true);
+    try {
+      const invitation = await services.readInvitationClipboard();
+      if (!open.current) return;
+      if (!invitation) {
+        toast.info(props.copy.joinTitle, props.copy.clipboardEmpty);
+        return;
+      }
+      setCode(invitation);
+      setJoinState({ kind: 'idle' });
+    } catch {
+      if (open.current) {
+        toast.error(props.copy.joinTitle, props.copy.clipboardUnavailable);
+      }
+    } finally {
+      if (open.current) setReadingClipboard(false);
+    }
+  }
+
   return (
     <Dialog
       isOpen
@@ -235,9 +260,22 @@ export function SessionCollaborationJoinDialog(props: {
                 value={code}
                 rows={6}
                 hasSpellCheck={false}
-                isDisabled={working}
-                onChange={setCode}
+                isDisabled={working || readingClipboard}
+                onChange={(value) => {
+                  setCode(value);
+                  if (joinState.kind === 'failed') setJoinState({ kind: 'idle' });
+                }}
               />
+              <div className="sessionCollaborationInvitationAction">
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  label={props.copy.pasteInvitation}
+                  isDisabled={working}
+                  isLoading={readingClipboard}
+                  onClick={() => void pasteInvitation()}
+                />
+              </div>
               {mounts.length > 0 ? (
                 <List density="compact" hasDividers aria-label={props.copy.retainedTasks}>
                   {mounts.map((mount) => (
@@ -272,7 +310,7 @@ export function SessionCollaborationJoinDialog(props: {
             <Button
               variant="primary"
               label={props.copy.join}
-              isDisabled={working || !code.trim()}
+              isDisabled={working || readingClipboard || !code.trim()}
               isLoading={working}
               onClick={() => void join()}
             />
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
index 9e07ced8d3..001d206b22 100644
--- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -57,6 +57,9 @@ const ZH = {
     joinTitle: '加入共享任务',
     joinDescription: '粘贴邀请码,建立独立的访客连接。',
     code: '邀请码',
+    pasteInvitation: '粘贴邀请码',
+    clipboardEmpty: '剪贴板中没有可粘贴的邀请码。',
+    clipboardUnavailable: '无法读取剪贴板;请手动粘贴邀请码。',
     join: '加入',
     validatingInvitation: '正在验证邀请码…',
     discoveringHost: '正在查找任务所在的 Runtime Host…',
@@ -146,6 +149,9 @@ const EN = {
     joinTitle: 'Join shared task',
     joinDescription: 'Paste an invitation to create an independent Guest connection.',
     code: 'Invitation code',
+    pasteInvitation: 'Paste invitation',
+    clipboardEmpty: 'There is no invitation to paste from the clipboard.',
+    clipboardUnavailable: 'Could not read the clipboard. Paste the invitation manually.',
     join: 'Join',
     validatingInvitation: 'Validating the invitation…',
     discoveringHost: 'Finding the Runtime Host for this task…',
diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts
index 850084e191..8cb25a7688 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts
@@ -29,6 +29,7 @@ export function createDesktopSessionCollaborationServices(
     importInvitation: (input, onProgress) =>
       bridge.sessionCollaboration.importInvitation(input, onProgress),
     cancelImport: (operationId) => bridge.sessionCollaboration.cancelImport(operationId),
+    readInvitationClipboard: () => bridge.sessionCollaboration.readInvitationClipboard(),
     listMounts: () => bridge.sessionCollaboration.listMounts(),
     removeMount: (mountId) => bridge.sessionCollaboration.removeMount(mountId),
     getPendingTurnRequests: () => bridge.sessionCollaboration.getPendingTurnRequests(),
diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
index 06c85e9634..c8628268ca 100644
--- a/apps/desktop/src/renderer/session-collaboration-dialog.tsx
+++ b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
@@ -306,7 +306,6 @@ function ShareSessionDialog(props: ShareSessionDialogProps) {
                       />
                     )
                   ) : null}
-                  <Button variant="secondary" label={copy.copy} onClick={() => void copyInvitation()} />
                 </FormLayout>
               ) : (
                 <Button
@@ -403,7 +402,24 @@ function ShareSessionDialog(props: ShareSessionDialogProps) {
             </div>
           </LayoutContent>
         )}
-        footer={<LayoutFooter><Button variant="secondary" label={copy.close} isDisabled={working} onClick={props.onClose} /></LayoutFooter>}
+        footer={(
+          <LayoutFooter>
+            <Button
+              variant="secondary"
+              label={copy.close}
+              isDisabled={working}
+              onClick={props.onClose}
+            />
+            {invitation ? (
+              <Button
+                variant="primary"
+                label={copy.copy}
+                isDisabled={working}
+                onClick={() => void copyInvitation()}
+              />
+            ) : null}
+          </LayoutFooter>
+        )}
       />
     </Dialog>
   );
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css
index d1d28b5e58..cb19a2bb5b 100644
--- a/apps/desktop/src/renderer/styles/settings/runtime-host.css
+++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css
@@ -835,6 +835,11 @@
   gap: var(--space-4);
 }
 
+.sessionCollaborationInvitationAction {
+  display: flex;
+  justify-content: flex-end;
+}
+
 .sessionCollaborationDisclosure {
   display: flex;
   flex-direction: column;


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


(maka) 10/11: docs(pr): refresh turn approval screenshot

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 14cfc9e4a8b4b9a02016e592f973dea7669fd628
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 21:03:25 2026 +0800

    docs(pr): refresh turn approval screenshot
---
 maka-turn-request-inline-approval.png | Bin 0 -> 840926 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)

diff --git a/maka-turn-request-inline-approval.png b/maka-turn-request-inline-approval.png
new file mode 100644
index 0000000000..2b15224a01
Binary files /dev/null and b/maka-turn-request-inline-approval.png differ


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


(maka) 11/11: chore: remove PR screenshot asset

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 4c1fc28185e5c6e6308d1646bd1746a29b1b2467
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 21:03:25 2026 +0800

    chore: remove PR screenshot asset
---
 maka-turn-request-inline-approval.png | Bin 840926 -> 0 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)

diff --git a/maka-turn-request-inline-approval.png b/maka-turn-request-inline-approval.png
deleted file mode 100644
index 2b15224a01..0000000000
Binary files a/maka-turn-request-inline-approval.png and /dev/null differ


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


(maka) 08/11: fix(peer): retain authenticated guest routes

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit c7e5a4b84e9705893a1c3ac672493289af187b50
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 20:13:58 2026 +0800

    fix(peer): retain authenticated guest routes
---
 .../runtime-host-guest-session-mounts.test.ts      | 68 +++++++++++++++++++++-
 apps/desktop/src/main/runtime-host-boot.ts         |  5 +-
 .../src/main/runtime-host-desktop-candidate.ts     |  5 ++
 .../src/main/runtime-host-desktop-manager.ts       |  8 ++-
 .../src/main/runtime-host-guest-session-mounts.ts  | 47 ++++++++++++++-
 .../src/__tests__/peer-listener.test.ts            |  1 +
 .../runtime-host/src/__tests__/peer-native.test.ts | 23 +++++++-
 .../__tests__/session-subscription-client.test.ts  | 11 +++-
 packages/runtime-host/src/client/connection.ts     | 20 +++++++
 packages/runtime-host/src/client/host-profile.ts   | 13 +++++
 packages/runtime-host/src/client/peer-client.ts    | 42 +++++++++++--
 11 files changed, 228 insertions(+), 15 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
index 49706eb3ce..ba5ee594c5 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
@@ -19,7 +19,11 @@
 
 import assert from 'node:assert/strict';
 import test from 'node:test';
-import { encodeCollaborationInvitationCode } from '@maka/runtime-host/protocol';
+import type { ResolvedRuntimeHostProfile } from '@maka/runtime-host/client';
+import {
+  encodeCollaborationInvitationCode,
+  type HostPeerEndpoint,
+} from '@maka/runtime-host/protocol';
 import { encodeDesktopCollaborationInvitation } from '../runtime-host-collaboration-invitation.js';
 import {
   createDesktopGuestSessionMountService,
@@ -57,6 +61,49 @@ test('retains a successful Guest mount and rehydrates the same authority after r
   await second!.close();
 });
 
+test('persists authenticated route rotation for reconnect and restart', async () => {
+  const store = memoryStore();
+  let observePeerEndpoint!: (endpoint: HostPeerEndpoint) => void;
+  const first = service(store, {
+    mount: async (target, _signal, _onConnectionPhase, onPeerEndpoint) => {
+      assert.deepEqual(
+        target.profile.kind === 'remote' ? target.profile.transport : undefined,
+        {
+          kind: 'libp2p-direct',
+          peerId: '12D3KooWpeer',
+          routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+          coordinationRelays: ['/memory/stale-relay'],
+        },
+      );
+      assert.ok(onPeerEndpoint);
+      observePeerEndpoint = onPeerEndpoint;
+    },
+  });
+
+  const imported = await first.importInvitation(peerInvitation('guest-routes'), false, 'routes');
+  assert.equal(imported.kind, 'connected');
+  observePeerEndpoint({
+    peerId: '12D3KooWpeer',
+    routeHints: ['/ip4/198.51.100.2/udp/42000/quic-v1'],
+    coordinationRelays: ['/memory/fresh-relay'],
+  });
+  await first.close();
+
+  let restarted!: ReturnType<typeof service>;
+  const restartedTarget = new Promise<ResolvedRuntimeHostProfile>((resolve) => {
+    restarted = service(store, { mount: async (target) => resolve(target) });
+    void restarted.start();
+  });
+  const target = await restartedTarget;
+  assert.deepEqual(target.profile.kind === 'remote' ? target.profile.transport : undefined, {
+    kind: 'libp2p-direct',
+    peerId: '12D3KooWpeer',
+    routeHints: ['/ip4/198.51.100.2/udp/42000/quic-v1'],
+    coordinationRelays: ['/memory/fresh-relay'],
+  });
+  await restarted.close();
+});
+
 test('removes failed activation desire instead of creating recoverable profile state', async () => {
   const store = memoryStore();
   const unmounted: string[] = [];
@@ -361,6 +408,25 @@ function invitation(credential: string): string {
   });
 }
 
+function peerInvitation(credential: string): string {
+  return encodeDesktopCollaborationInvitation({
+    invitationCode: encodeCollaborationInvitationCode({
+      schemaVersion: 1,
+      rootId: ROOT_ID,
+      credential,
+    }),
+    target: {
+      name: 'Shared Host',
+      transport: {
+        kind: 'libp2p-direct',
+        peerId: '12D3KooWpeer',
+        routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+        coordinationRelays: ['/memory/stale-relay'],
+      },
+    },
+  });
+}
+
 function retainedMount(mountId: string): GuestSessionMount {
   return {
     mountId,
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 893f54e62f..90d6898633 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -532,7 +532,7 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({
 });
 const guestSessionMountService = createDesktopGuestSessionMountService({
   store: createGuestSessionMountStore(runtimeHostCredentialStore),
-  mount: async (target, signal, onConnectionPhase) => {
+  mount: async (target, signal, onConnectionPhase, onPeerEndpoint) => {
     if (target.profile.kind !== 'remote' || !target.credential) {
       throw new Error('A shared Session requires a remote Guest target');
     }
@@ -541,6 +541,9 @@ const guestSessionMountService = createDesktopGuestSessionMountService({
       { profile: target.profile, credential: target.credential },
       signal,
       onConnectionPhase,
+      (status) => {
+        if (status.peerEndpoint) onPeerEndpoint?.(status.peerEndpoint);
+      },
     );
   },
   finalizeAccess: async (mountId, signal) => {
diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
index f621beb29a..8f2d460c44 100644
--- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts
@@ -52,6 +52,7 @@ import {
 import {
   INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
   RUNTIME_HOST_PROTOCOL_VERSION,
+  type HostStatusResult,
   type WorkspaceTarget,
 } from "@maka/runtime-host/protocol";
 import type { AttachmentApprovalRegistry } from "./attachment-approval.js";
@@ -204,6 +205,7 @@ export interface DesktopRuntimeHostCandidateStartInput
   readonly candidateLaunchBarrier?: RuntimeHostCandidateLaunchBarrier;
   readonly peerClient?: RuntimeHostPeerClient;
   readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void;
+  readonly onHostStatus?: (status: HostStatusResult) => void;
   readonly profileTarget?: {
     readonly profile: PersistedRuntimeHostProfile;
     readonly credential?: string;
@@ -440,6 +442,9 @@ async function startProfileDesktopRuntimeHostCandidate(
     ...(input.onConnectionPhase === undefined
       ? {}
       : { onConnectionPhase: input.onConnectionPhase }),
+    ...(input.onHostStatus === undefined
+      ? {}
+      : { onHostStatus: input.onHostStatus }),
     ...(profileTarget.sshInteraction === undefined
       ? {}
       : { sshInteraction: profileTarget.sshInteraction }),
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index 7953e0e1d8..de155eeb53 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -36,7 +36,7 @@ import {
   type RuntimeHostRetirementMode,
   type RuntimeHostSshInteraction,
 } from '@maka/runtime-host/client';
-import type { HostRegistration } from '@maka/runtime-host/protocol';
+import type { HostRegistration, HostStatusResult } from '@maka/runtime-host/protocol';
 import type { DesktopTargetSessionRef } from '../shared/runtime-host-identity.js';
 import {
   startDesktopRuntimeHostCandidate,
@@ -72,6 +72,7 @@ export interface RuntimeHostDesktopManager {
     profileTarget: NonNullable<DesktopRuntimeHostCandidateStartInput['profileTarget']>,
     signal?: AbortSignal,
     onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void,
+    onHostStatus?: (status: HostStatusResult) => void,
   ): Promise<void>;
   finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise<void>;
   unmountGuest(mountId: string): Promise<void>;
@@ -503,12 +504,13 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     profileTarget: NonNullable<DesktopRuntimeHostCandidateStartInput['profileTarget']>,
     signal?: AbortSignal,
     onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void,
+    onHostStatus?: (status: HostStatusResult) => void,
   ): Promise<void> {
     if (!isSessionGuestProfile(profileTarget.profile)) {
       return Promise.reject(new Error('A Session Guest target is required'));
     }
     return this.#mutateTarget(profileTarget.profile.id, () =>
-      this.#enable(profileTarget, true, signal, onConnectionPhase),
+      this.#enable(profileTarget, true, signal, onConnectionPhase, onHostStatus),
     );
   }
 
@@ -517,6 +519,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     allowSameRoot: boolean,
     signal?: AbortSignal,
     onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void,
+    onHostStatus?: (status: HostStatusResult) => void,
   ): Promise<void> {
     signal?.throwIfAborted();
     if (this.#closed) throw new Error('Desktop Runtime Host manager is closed');
@@ -547,6 +550,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
     const target = this.#createTarget({
       ...withRuntimeHostTarget(this.#baseInput, profileTarget),
       ...(onConnectionPhase ? { onConnectionPhase } : {}),
+      ...(onHostStatus ? { onHostStatus } : {}),
     });
     this.#targets.set(profileId, target);
     this.#publishState(target, {
diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
index 68024201dc..4b9e5a97e6 100644
--- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
+++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
@@ -25,7 +25,10 @@ import {
   type RuntimeHostConnectionPhase,
   type RuntimeHostRemoteTransport,
 } from '@maka/runtime-host/client';
-import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol';
+import {
+  decodeCollaborationInvitationCode,
+  type HostPeerEndpoint,
+} from '@maka/runtime-host/protocol';
 import type { CredentialStore } from '@maka/storage/credential-store';
 import type {
   SessionCollaborationCancelResult,
@@ -130,6 +133,7 @@ export function createDesktopGuestSessionMountService(input: {
     target: ResolvedRuntimeHostProfile,
     signal: AbortSignal,
     onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void,
+    onPeerEndpoint?: (endpoint: HostPeerEndpoint) => void,
   ) => Promise<void>;
   readonly finalizeAccess: (mountId: string, signal: AbortSignal) => Promise<void>;
   readonly unmount: (mountId: string) => Promise<void>;
@@ -165,6 +169,38 @@ export function createDesktopGuestSessionMountService(input: {
     mounts = next;
   };
 
+  const recordPeerEndpoint = (mount: GuestSessionMount, endpoint: HostPeerEndpoint): void => {
+    if (
+      closed ||
+      mount.transport.kind !== 'libp2p-direct' ||
+      endpoint.peerId !== mount.transport.peerId ||
+      (endpoint.routeHints.length === 0 && endpoint.coordinationRelays.length === 0)
+    ) return;
+    void mutate(async () => {
+      if (removingMounts.has(mount.mountId)) return;
+      const current = await load();
+      const retained = current.get(mount.mountId);
+      if (
+        retained?.transport.kind !== 'libp2p-direct' ||
+        retained.transport.peerId !== endpoint.peerId ||
+        (
+          sameStrings(retained.transport.routeHints, endpoint.routeHints) &&
+          sameStrings(retained.transport.coordinationRelays, endpoint.coordinationRelays)
+        )
+      ) return;
+      const updated = decodeMount({
+        ...retained,
+        transport: {
+          kind: 'libp2p-direct',
+          peerId: endpoint.peerId,
+          routeHints: endpoint.routeHints,
+          coordinationRelays: endpoint.coordinationRelays,
+        },
+      });
+      await persist(new Map(current).set(mount.mountId, updated));
+    }).catch((error: unknown) => onError(asError(error), mount));
+  };
+
   const activate = async (
     activation: LiveGuestActivation,
     mount: GuestSessionMount,
@@ -177,7 +213,10 @@ export function createDesktopGuestSessionMountService(input: {
           collaborationProgressForConnectionPhase(phase),
         );
       }
-    });
+    }, (endpoint) => recordPeerEndpoint(mount, endpoint));
+    // waitForReady observes host.status before mount resolves. Commit that
+    // authenticated route snapshot before declaring the durable mount ready.
+    await mutationTail;
     activation.controller.signal.throwIfAborted();
     if (removingMounts.has(mount.mountId)) {
       throw new Error('Shared Session mount was removed while connecting');
@@ -524,6 +563,10 @@ function isPeerPathUnavailable(error: unknown): boolean {
   return error.code === 'direct_path_unavailable' || error.code === 'transit_unavailable';
 }
 
+function sameStrings(left: readonly string[], right: readonly string[]): boolean {
+  return left.length === right.length && left.every((value, index) => value === right[index]);
+}
+
 function collaborationProgressForConnectionPhase(
   phase: RuntimeHostConnectionPhase,
 ): SessionCollaborationImportPhase {
diff --git a/packages/runtime-host/src/__tests__/peer-listener.test.ts b/packages/runtime-host/src/__tests__/peer-listener.test.ts
index e6ec103e18..e0d40ff411 100644
--- a/packages/runtime-host/src/__tests__/peer-listener.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-listener.test.ts
@@ -168,6 +168,7 @@ function peerWith(streams: RuntimeHostPeerNativeStream[]): RuntimeHostPeerClient
       maxCircuitBytes: 256 * 1024 * 1024,
     }),
     configureTransit: async () => undefined,
+    observeAuthenticatedRoutes: () => undefined,
     connect: async () => {
       throw new Error('not used');
     },
diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts
index 30cf3924bc..1e8c2d033b 100644
--- a/packages/runtime-host/src/__tests__/peer-native.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-native.test.ts
@@ -73,7 +73,7 @@ module.exports = {
       connect: ({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }) => {
         stats.requests.push({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds });
         if (peerId === 'unreachable') return Promise.reject(Object.assign(new Error('transit_unavailable: no approved route'), { code: 'GenericFailure' }));
-        if (peerId === 'ready' || peerId === 'fallback' || (peerId === 'self-contained' && selfContainedRoutesPrepared)) return Promise.resolve(stream);
+        if (peerId === 'ready' || peerId === 'fallback' || peerId === 'observed' || (peerId === 'self-contained' && selfContainedRoutesPrepared)) return Promise.resolve(stream);
         return new Promise((resolve, reject) => pending.set(requestId, { resolve, reject }));
       },
       connectMeshControl: ({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }) => {
@@ -118,8 +118,8 @@ module.exports = {
             await client.connectMeshControl(peerConnectInput(peerId));
           }
         },
-        resolveRoutes: () =>
-          routesPrepared
+        resolveRoutes: (peerId) =>
+          routesPrepared && peerId !== 'observed'
             ? {
                 routeHints: ['/memory/discovered'],
                 coordinationRelays: ['/memory/relay'],
@@ -167,6 +167,16 @@ module.exports = {
     });
     await selfContained;
     assert.equal(preparedPeerIds.includes('self-contained'), true);
+    client.observeAuthenticatedRoutes({
+      peerId: 'observed',
+      routeHints: ['/memory/fresh'],
+      coordinationRelays: ['/memory/fresh-relay'],
+    });
+    await client.connect({
+      ...peerConnectInput('observed'),
+      routeHints: ['/memory/stale'],
+      coordinationRelays: ['/memory/stale-relay'],
+    });
     assert.deepEqual(native.default.stats, {
       starts: 1,
       closes: 0,
@@ -227,6 +237,13 @@ module.exports = {
           coordinationRelays: ['/memory/relay', '/memory/explicit-relay'],
           transitRelayPeerIds: ['transit-peer'],
         },
+        {
+          requestId: 9,
+          peerId: 'observed',
+          routeHints: ['/memory/fresh', '/memory/stale'],
+          coordinationRelays: ['/memory/fresh-relay', '/memory/stale-relay'],
+          transitRelayPeerIds: [],
+        },
       ],
       cancellations: [1, 1],
     });
diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
index b3c68dc297..7f5ba135a3 100644
--- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
+++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
@@ -55,6 +55,7 @@ import {
   type SessionTranscriptFragment,
   type SessionTranscriptPage,
   type HostFrame,
+  type HostStatusResult,
   type RequestFrame,
   type SubscriptionFrame,
 } from '../protocol/index.js';
@@ -1277,6 +1278,7 @@ test('close stops transcript pagination after the in-flight page', async () => {
 
 test('probes an otherwise idle accepted Runtime Host connection', { timeout: 2_000 }, async () => {
   const probed = deferred<void>();
+  const observed = deferred<HostStatusResult>();
   await withProtocolPeer(
     async (transport, hostEpoch, rootId) => {
       const hello = decodeClientFrame(await transport.read(1_000));
@@ -1294,10 +1296,16 @@ test('probes an otherwise idle accepted Runtime Host connection', { timeout: 2_0
       });
       await answerStatus(transport, hostEpoch);
     },
-    async () => probed.promise,
+    async () => {
+      const status = await observed.promise;
+      assert.equal(status.state, 'ready');
+      assert.equal(status.compositionId, 'maka.interactive');
+      await probed.promise;
+    },
     {
       livenessIntervalMs: 20,
       onLivenessProbe: probed.resolve,
+      onHostStatus: observed.resolve,
     },
   );
 });
@@ -1308,6 +1316,7 @@ async function withProtocolPeer(
   connectionOptions: {
     readonly livenessIntervalMs?: number;
     readonly onLivenessProbe?: () => void;
+    readonly onHostStatus?: (status: HostStatusResult) => void;
   } = {},
 ): Promise<void> {
   const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-subscription-'));
diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts
index 42161db3c8..60805de924 100644
--- a/packages/runtime-host/src/client/connection.ts
+++ b/packages/runtime-host/src/client/connection.ts
@@ -110,6 +110,8 @@ export interface ConnectRuntimeHostInput {
    * connection health.
    */
   onLivenessProbe?: () => void;
+  /** Receives each identity-validated Host status observation. */
+  onHostStatus?: (status: HostStatusResult) => void;
 }
 
 export type RuntimeHostUnavailableReason =
@@ -163,6 +165,7 @@ export interface ConnectRemoteRuntimeHostInput {
   readonly handshakeTimeoutMs?: number;
   readonly livenessIntervalMs?: number;
   readonly onLivenessProbe?: () => void;
+  readonly onHostStatus?: (status: HostStatusResult) => void;
   readonly connectionResource?: RuntimeHostConnectionResource;
 }
 
@@ -175,6 +178,7 @@ export interface ConnectRuntimeHostMessageTransportInput {
   readonly handshakeTimeoutMs?: number;
   readonly livenessIntervalMs?: number;
   readonly onLivenessProbe?: () => void;
+  readonly onHostStatus?: (status: HostStatusResult) => void;
   readonly connectionResource?: RuntimeHostConnectionResource;
   readonly peerPath?: RuntimeHostPeerConnectionPath;
 }
@@ -352,6 +356,7 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
   #terminalError: Error | undefined;
   readonly #livenessIntervalMs: number;
   readonly #onLivenessProbe: (() => void) | undefined;
+  readonly #onHostStatus: ((status: HostStatusResult) => void) | undefined;
 
   constructor(
     transport: RuntimeHostMessageTransport,
@@ -368,12 +373,14 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
     options?: {
       livenessIntervalMs?: number;
       onLivenessProbe?: () => void;
+      onHostStatus?: (status: HostStatusResult) => void;
       connectionResource?: RuntimeHostConnectionResource;
       peerPath?: RuntimeHostPeerConnectionPath;
     },
   ) {
     this.#livenessIntervalMs = options?.livenessIntervalMs ?? DEFAULT_LIVENESS_INTERVAL_MS;
     this.#onLivenessProbe = options?.onLivenessProbe;
+    this.#onHostStatus = options?.onHostStatus;
     this.#transport = transport;
     this.rootId = accepted.rootId;
     this.hostEpoch = accepted.hostEpoch;
@@ -547,6 +554,11 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
       this.#fail(error);
       throw error;
     }
+    try {
+      this.#onHostStatus?.(status);
+    } catch {
+      // Observation cannot control the authenticated connection it watches.
+    }
     return status;
   }
 
@@ -790,6 +802,9 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
   }
 
   #resetLivenessCheck(): void {
+    // A status observer needs periodic route observations even while other
+    // Session traffic keeps the connection active.
+    if (this.#onHostStatus) return;
     if (this.#livenessTimer) clearTimeout(this.#livenessTimer);
     this.#livenessTimer = undefined;
     this.#scheduleLivenessCheck();
@@ -1000,6 +1015,7 @@ export async function connectRemoteRuntimeHost(
       handshakeTimeoutMs: normalized.handshakeTimeoutMs,
       livenessIntervalMs: normalized.livenessIntervalMs,
       onLivenessProbe: input.onLivenessProbe,
+      onHostStatus: input.onHostStatus,
       connectionResource: input.connectionResource,
     });
   } catch (error) {
@@ -1030,6 +1046,7 @@ export async function connectRuntimeHostMessageTransport(
       expectedRootId,
       livenessIntervalMs: normalized.livenessIntervalMs,
       onLivenessProbe: input.onLivenessProbe,
+      onHostStatus: input.onHostStatus,
       connectionResource: input.connectionResource,
       ...(input.peerPath ? { peerPath: input.peerPath } : {}),
     });
@@ -1241,6 +1258,7 @@ export async function connectResolvedRuntimeHost(
       hostProtocol: { min: registration.protocolMin, max: registration.protocolMax },
       livenessIntervalMs,
       onLivenessProbe: input.onLivenessProbe,
+      onHostStatus: input.onHostStatus,
     });
     if (result.kind === 'connected') {
       if (
@@ -1338,6 +1356,7 @@ interface ExchangeRuntimeHostHandshakeInput {
   readonly expectedCompositionRevision?: string;
   readonly livenessIntervalMs?: number;
   readonly onLivenessProbe?: () => void;
+  readonly onHostStatus?: (status: HostStatusResult) => void;
   readonly connectionResource?: RuntimeHostConnectionResource;
   readonly peerPath?: RuntimeHostPeerConnectionPath;
 }
@@ -1418,6 +1437,7 @@ async function exchangeRuntimeHostHandshake(
     connection: new RuntimeHostConnectionImpl(input.transport, handshake, {
       livenessIntervalMs: input.livenessIntervalMs,
       onLivenessProbe: input.onLivenessProbe,
+      onHostStatus: input.onHostStatus,
       connectionResource: input.connectionResource,
       ...(input.peerPath ? { peerPath: input.peerPath } : {}),
     }),
diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts
index feab8c1fd6..1e77cb64bb 100644
--- a/packages/runtime-host/src/client/host-profile.ts
+++ b/packages/runtime-host/src/client/host-profile.ts
@@ -27,6 +27,7 @@ import {
   INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
   isCanonicalRuntimeHostWebSocketPath,
   RUNTIME_HOST_PROTOCOL_VERSION,
+  type HostStatusResult,
   requireClientInstanceId,
   requireHostRootId,
 } from '../protocol/index.js';
@@ -383,6 +384,7 @@ export async function connectRuntimeHostProfile(
     readonly sshInteraction?: RuntimeHostSshInteraction;
     readonly peerClient?: RuntimeHostPeerClient;
     readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void;
+    readonly onHostStatus?: (status: HostStatusResult) => void;
   },
   overrides: {
     connect?: typeof connectRemoteRuntimeHost;
@@ -439,6 +441,7 @@ export async function connectRemoteRuntimeHostProfile(
     readonly sshInteraction?: RuntimeHostSshInteraction;
     readonly peerClient?: RuntimeHostPeerClient;
     readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void;
+    readonly onHostStatus?: (status: HostStatusResult) => void;
   },
   overrides: {
     connect?: typeof connectRemoteRuntimeHost;
@@ -467,6 +470,7 @@ export async function connectRemoteRuntimeHostProfile(
       ...(input.onConnectionPhase === undefined
         ? {}
         : { onConnectionPhase: input.onConnectionPhase }),
+      ...(input.onHostStatus === undefined ? {} : { onHostStatus: input.onHostStatus }),
     });
   } else {
     notifyConnectionPhase(input.onConnectionPhase, 'connecting');
@@ -512,6 +516,7 @@ export async function connectRemoteRuntimeHostProfile(
       ...(input.handshakeTimeoutMs === undefined
         ? {}
         : { handshakeTimeoutMs: input.handshakeTimeoutMs }),
+      ...(input.onHostStatus === undefined ? {} : { onHostStatus: input.onHostStatus }),
     });
     try {
       input.signal?.throwIfAborted();
@@ -587,6 +592,7 @@ export async function connectPeerRuntimeHost(input: {
   readonly connectTimeoutMs?: number;
   readonly handshakeTimeoutMs?: number;
   readonly onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void;
+  readonly onHostStatus?: (status: HostStatusResult) => void;
 }): Promise<RuntimeHostConnection> {
   input.signal?.throwIfAborted();
   const handshakeTimeoutMs = input.handshakeTimeoutMs ?? DEFAULT_PEER_HANDSHAKE_TIMEOUT_MS;
@@ -631,6 +637,13 @@ export async function connectPeerRuntimeHost(input: {
       },
       clientInstanceId: input.clientInstanceId,
       handshakeTimeoutMs,
+      onHostStatus: (status) => {
+        const endpoint = status.peerEndpoint;
+        if (endpoint?.peerId === input.transport.peerId) {
+          input.peerClient.observeAuthenticatedRoutes(endpoint);
+        }
+        input.onHostStatus?.(status);
+      },
       ...(stream.path ? { peerPath: stream.path } : {}),
     });
     input.signal?.throwIfAborted();
diff --git a/packages/runtime-host/src/client/peer-client.ts b/packages/runtime-host/src/client/peer-client.ts
index 543efdb2f0..705ba0f9fb 100644
--- a/packages/runtime-host/src/client/peer-client.ts
+++ b/packages/runtime-host/src/client/peer-client.ts
@@ -66,6 +66,11 @@ export interface RuntimeHostPeerClient {
     readonly approvedRelayPeerIds: readonly string[];
     readonly relayCandidates: readonly RuntimeHostPeerTransitRelayCandidate[];
   }): Promise<void>;
+  observeAuthenticatedRoutes(input: {
+    readonly peerId: string;
+    readonly routeHints: readonly string[];
+    readonly coordinationRelays: readonly string[];
+  }): void;
   connect(
     input: RuntimeHostPeerConnectInput,
     signal?: AbortSignal,
@@ -129,6 +134,13 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
   readonly #automaticRelayDiscovery: boolean;
   readonly #webRtcStunUrls: readonly string[] | undefined;
   readonly #routeResolver: RuntimeHostPeerRouteResolver | undefined;
+  readonly #authenticatedRoutes = new Map<
+    string,
+    Readonly<{
+      routeHints: readonly string[];
+      coordinationRelays: readonly string[];
+    }>
+  >();
   #endpoint: RuntimeHostPeerNativeEndpoint | undefined;
   #draining: Promise<void> | undefined;
   #meshDraining: Promise<void> | undefined;
@@ -210,6 +222,21 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
       });
   }
 
+  observeAuthenticatedRoutes(input: {
+    readonly peerId: string;
+    readonly routeHints: readonly string[];
+    readonly coordinationRelays: readonly string[];
+  }): void {
+    if (input.routeHints.length === 0 && input.coordinationRelays.length === 0) return;
+    this.#authenticatedRoutes.set(
+      input.peerId,
+      Object.freeze({
+        routeHints: Object.freeze([...input.routeHints]),
+        coordinationRelays: Object.freeze([...input.coordinationRelays]),
+      }),
+    );
+  }
+
   async connect(
     input: RuntimeHostPeerConnectInput,
     signal?: AbortSignal,
@@ -337,13 +364,18 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     const requestId = this.#allocateRequestId();
     const discovered =
       kind === 'application' ? this.#routeResolver?.resolveRoutes(input.peerId) : undefined;
+    const authenticated =
+      kind === 'application' ? this.#authenticatedRoutes.get(input.peerId) : undefined;
     const connection = endpoint[kind === 'application' ? 'connect' : 'connectMeshControl']({
       ...input,
-      routeHints: mergeAddresses(discovered?.routeHints ?? [], input.routeHints),
-      coordinationRelays: mergeAddresses(
-        discovered?.coordinationRelays ?? [],
-        input.coordinationRelays,
-      ),
+      routeHints: mergeAddresses(discovered?.routeHints ?? [], [
+        ...(authenticated?.routeHints ?? []),
+        ...input.routeHints,
+      ]),
+      coordinationRelays: mergeAddresses(discovered?.coordinationRelays ?? [], [
+        ...(authenticated?.coordinationRelays ?? []),
+        ...(input.coordinationRelays ?? []),
+      ]),
       transitRelayPeerIds: mergeValues(
         discovered?.transitRelayPeerIds ?? [],
         input.transitRelayPeerIds,


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


(maka) 04/11: fix(peer): recover idle shared-session connections

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit d74e05a3c7e9d33fc10caf7eda8c1884d1b9401a
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 18:38:29 2026 +0800

    fix(peer): recover idle shared-session connections
---
 .../runtime-host-session-catalog-preload.test.ts   | 35 +++++++++
 apps/desktop/src/preload/bridge-contract.d.ts      |  1 +
 apps/desktop/src/preload/preload.ts                | 74 +++++++++++++------
 .../src/preload/runtime-host-session-catalog.ts    | 26 +++++++
 .../runtime-host/src/__tests__/peer-mesh.test.ts   | 82 +++++++++++++++++++++-
 .../runtime-host/src/__tests__/peer-native.test.ts | 12 +++-
 .../__tests__/session-subscription-client.test.ts  | 32 +++++++++
 packages/runtime-host/src/client/connection.ts     | 30 ++------
 packages/runtime-host/src/client/peer-client.ts    | 14 ++--
 packages/runtime-host/src/peer-mesh/node.ts        | 63 +++++++++++++----
 10 files changed, 303 insertions(+), 66 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts
index e44732fbe7..3274cf1f0c 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-preload.test.ts
@@ -23,6 +23,7 @@ import type { DesktopSessionSummary } from '../../preload/bridge-contract.js';
 import {
   collectRuntimeHostSessionCatalogs,
   collectRuntimeHostSessionCatalogsWithCoverage,
+  reconcileRuntimeHostSessionCatalog,
 } from '../../preload/runtime-host-session-catalog.js';
 
 function session(id: string, activityAt: number): DesktopSessionSummary {
@@ -65,6 +66,40 @@ test('collapses overlapping Guest catalogs in favor of the Owner authority', asy
   assert.deepEqual(sessions, [owner]);
 });
 
+test('retains a Guest catalog row only while its Runtime Host profile remains known', () => {
+  const shared = {
+    ...session('shared-session', 2),
+    runtimeHostId: 'host-guest',
+    profileId: 'guest-profile',
+    shared: true as const,
+  };
+
+  const reconnecting = reconcileRuntimeHostSessionCatalog([shared], {
+    sessions: [],
+    completeHostIds: [],
+    knownProfileIds: ['guest-profile'],
+  });
+  assert.deepEqual(reconnecting, [shared]);
+
+  assert.deepEqual(
+    reconcileRuntimeHostSessionCatalog(reconnecting, {
+      sessions: [{ ...shared, activityAt: 3 }],
+      completeHostIds: ['host-guest'],
+      knownProfileIds: ['guest-profile'],
+    }),
+    [{ ...shared, activityAt: 3 }],
+  );
+
+  assert.deepEqual(
+    reconcileRuntimeHostSessionCatalog(reconnecting, {
+      sessions: [],
+      completeHostIds: [],
+      knownProfileIds: [],
+    }),
+    [],
+  );
+});
+
 test('fails when every Host catalog rejects', async () => {
   await assert.rejects(
     collectRuntimeHostSessionCatalogs([
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 8d9d0dc8a1..609c881e0c 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -1037,6 +1037,7 @@ export interface MakaBridge {
     listWithCoverage(): Promise<{
       sessions: DesktopSessionSummary[];
       completeHostIds: string[];
+      knownProfileIds: string[];
     }>;
     create(input?: CreateSessionRequestInput): Promise<DesktopSessionSummary>;
     send(
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index da6809c8aa..466c15f8a7 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -121,8 +121,8 @@ import type { BotProvider } from '@maka/core/bot-chat-settings';
 import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/bot-onboarding';
 import type { HealthSnapshot } from '@maka/core/health';
 import {
-  collectRuntimeHostSessionCatalogs,
   collectRuntimeHostSessionCatalogsWithCoverage,
+  reconcileRuntimeHostSessionCatalog,
 } from './runtime-host-session-catalog.js';
 import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
@@ -262,6 +262,7 @@ const runtimeHostMetadata = new Map<
   }
 >();
 const runtimeHostSessionScopes = new Map<string, RuntimeHostScopeKey>();
+let lastDesktopSessionCatalog: DesktopSessionSummary[] = [];
 const newTaskChangeListeners = new Set<() => void>();
 let previousMainProcessInterruptionRead: Promise<boolean> | undefined;
 
@@ -362,30 +363,66 @@ function recordRuntimeHostIdentity(value: unknown): {
   return { scope, readiness: metadata.readiness };
 }
 
-async function runtimeHostScopeList(): Promise<readonly DesktopTargetScope[]> {
+interface RuntimeHostScopeInventory {
+  readonly readyScopes: readonly DesktopTargetScope[];
+  readonly knownProfileIds: readonly string[];
+}
+
+async function runtimeHostScopeInventory(): Promise<RuntimeHostScopeInventory> {
   while (true) {
     const generation = activeRuntimeHostGeneration;
-    const identities: unknown = await ipcRenderer.invoke('runtime-host:identities');
+    const [identities, guestMounts]: [unknown, unknown] = await Promise.all([
+      ipcRenderer.invoke('runtime-host:identities'),
+      ipcRenderer.invoke('session-collaboration:mount:list'),
+    ]);
     if (generation !== activeRuntimeHostGeneration) continue;
     if (!Array.isArray(identities)) {
       throw new Error('Desktop Runtime Host identities are unavailable');
     }
+    if (!Array.isArray(guestMounts)) {
+      throw new Error('Desktop shared Session mounts are unavailable');
+    }
     const authoritativeScopeKeys = new Set<RuntimeHostScopeKey>();
     const readyScopes: DesktopTargetScope[] = [];
+    const knownProfileIds = new Set(
+      guestMounts.map((mount) => {
+        if (
+          !mount ||
+          typeof mount !== 'object' ||
+          !('mountId' in mount) ||
+          typeof mount.mountId !== 'string'
+        ) {
+          throw new Error('Desktop shared Session mount is invalid');
+        }
+        return mount.mountId;
+      }),
+    );
     for (const identity of identities) {
       const { scope, readiness } = recordRuntimeHostIdentity(identity);
       authoritativeScopeKeys.add(runtimeHostScopeKey(scope));
-      if (readiness === 'ready') readyScopes.push(scope);
+      const metadata = runtimeHostMetadataFor(scope);
+      if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable');
+      knownProfileIds.add(metadata.profileId);
+      if (readiness === 'ready') {
+        readyScopes.push(scope);
+      }
     }
     for (const scopeKey of runtimeHostScopes.keys()) {
       if (authoritativeScopeKeys.has(scopeKey)) continue;
       runtimeHostScopes.delete(scopeKey);
       runtimeHostMetadata.delete(scopeKey);
     }
-    return readyScopes;
+    return {
+      readyScopes,
+      knownProfileIds: [...knownProfileIds],
+    };
   }
 }
 
+async function runtimeHostScopeList(): Promise<readonly DesktopTargetScope[]> {
+  return (await runtimeHostScopeInventory()).readyScopes;
+}
+
 async function runtimeHostSessionRef(sessionId: string): Promise<{
   readonly scope: DesktopTargetScope;
   readonly sessionId: string;
@@ -896,28 +933,22 @@ async function listDesktopSessions(
     ) as SessionCatalogSummary[];
     return sessions.map((session) => projectSessionSummary(parent.scope, session));
   }
-  const scopes = await runtimeHostScopeList();
-  const sessions = await collectRuntimeHostSessionCatalogs(
-    scopes.map(async (scope) => {
-      const sessions = await ipcRenderer.invoke(
-        'sessions:list',
-        scope,
-        filter,
-      ) as SessionCatalogSummary[];
-      return sessions.map((session) => projectSessionCatalogSummary(scope, session));
-    }),
+  const snapshot = await listDesktopSessionsWithCoverage();
+  lastDesktopSessionCatalog = reconcileRuntimeHostSessionCatalog(
+    lastDesktopSessionCatalog,
+    snapshot,
   );
-  recordSessionCatalogScopes(sessions);
-  return sessions;
+  return lastDesktopSessionCatalog;
 }
 
 async function listDesktopSessionsWithCoverage(): Promise<{
   sessions: DesktopSessionSummary[];
   completeHostIds: string[];
+  knownProfileIds: string[];
 }> {
-  const scopes = await runtimeHostScopeList();
+  const inventory = await runtimeHostScopeInventory();
   const catalog = await collectRuntimeHostSessionCatalogsWithCoverage(
-    scopes.map((scope) => {
+    inventory.readyScopes.map((scope) => {
       const metadata = runtimeHostMetadataFor(scope);
       if (!metadata) throw new Error('Desktop Runtime Host metadata is unavailable');
       return {
@@ -930,7 +961,10 @@ async function listDesktopSessionsWithCoverage(): Promise<{
     }),
   );
   recordSessionCatalogScopes(catalog.sessions);
-  return catalog;
+  return {
+    ...catalog,
+    knownProfileIds: [...inventory.knownProfileIds],
+  };
 }
 
 async function createDesktopSessionOnScope(
diff --git a/apps/desktop/src/preload/runtime-host-session-catalog.ts b/apps/desktop/src/preload/runtime-host-session-catalog.ts
index d4755a8949..8a274b3893 100644
--- a/apps/desktop/src/preload/runtime-host-session-catalog.ts
+++ b/apps/desktop/src/preload/runtime-host-session-catalog.ts
@@ -30,6 +30,11 @@ export interface RuntimeHostSessionCatalogCoverage {
   readonly completeHostIds: string[];
 }
 
+export interface RuntimeHostSessionCatalogSnapshot extends RuntimeHostSessionCatalogCoverage {
+  /** Profiles still retained by Desktop, including unavailable Guest mounts. */
+  readonly knownProfileIds: string[];
+}
+
 export async function collectRuntimeHostSessionCatalogsWithCoverage(
   requests: readonly RuntimeHostSessionCatalogRequest[],
 ): Promise<RuntimeHostSessionCatalogCoverage> {
@@ -73,6 +78,27 @@ export async function collectRuntimeHostSessionCatalogs(
   return sortSessionCatalogs(groups.flat());
 }
 
+/**
+ * Commits complete Host catalogs authoritatively while retaining the last
+ * accepted rows for a Host that still exists but cannot answer this read.
+ * An explicitly removed profile is absent from knownProfileIds and therefore
+ * retires immediately; transport availability alone cannot change access.
+ */
+export function reconcileRuntimeHostSessionCatalog(
+  current: readonly DesktopSessionSummary[],
+  snapshot: RuntimeHostSessionCatalogSnapshot,
+): DesktopSessionSummary[] {
+  const completeHostIds = new Set(snapshot.completeHostIds);
+  const knownProfileIds = new Set(snapshot.knownProfileIds);
+  return sortSessionCatalogs([
+    ...snapshot.sessions,
+    ...current.filter(
+      (session) =>
+        knownProfileIds.has(session.profileId) && !completeHostIds.has(session.runtimeHostId),
+    ),
+  ]);
+}
+
 function sortSessionCatalogs(sessions: DesktopSessionSummary[]): DesktopSessionSummary[] {
   const unique = new Map<string, DesktopSessionSummary>();
   for (const session of sessions) {
diff --git a/packages/runtime-host/src/__tests__/peer-mesh.test.ts b/packages/runtime-host/src/__tests__/peer-mesh.test.ts
index bdeba658d1..3fc31ab762 100644
--- a/packages/runtime-host/src/__tests__/peer-mesh.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-mesh.test.ts
@@ -352,6 +352,57 @@ test('reconciles changed routes, propagates removal, and recovers the verified c
   }
 });
 
+test('publishes a changed local route promptly and refreshes a live cached peer route', {
+  timeout: 10_000,
+}, async () => {
+  const root = await mkdtemp(join(tmpdir(), 'maka-peer-mesh-route-liveness-'));
+  const network = new MemoryPeerNetwork();
+  const authorityPeer = network.create('peer-a');
+  const memberBPeer = network.create('peer-b');
+  const memberCPeer = network.create('peer-c');
+  const authority = await openPeerMeshNode({
+    dataRoot: join(root, 'authority'),
+    peer: authorityPeer,
+  });
+  const memberB = await openPeerMeshNode({
+    dataRoot: join(root, 'member-b'),
+    peer: memberBPeer,
+  });
+  const memberC = await openPeerMeshNode({
+    dataRoot: join(root, 'member-c'),
+    peer: memberCPeer,
+  });
+  const serving = [authority.serve(), memberB.serve(), memberC.serve()];
+  try {
+    const mesh = await authority.create();
+    await memberB.join(await authority.invite(mesh.roster.roster.meshId));
+    await memberC.join(await authority.invite(mesh.roster.roster.meshId));
+    await memberC.reconcile();
+    assert.deepEqual(memberC.resolveRoutes('peer-b')?.routeHints, ['/memory/peer-b/p2p/peer-b']);
+
+    const movedRoute = '/memory/peer-b-restarted/p2p/peer-b';
+    const movedRelay = '/memory/relay/peer-b-restarted';
+    memberBPeer.setRouteHints([movedRoute]);
+    memberBPeer.setCoordinationRelays([movedRelay]);
+    await waitForRoutes(authority, 'peer-b', [movedRoute], [movedRelay]);
+
+    assert.deepEqual(memberC.resolveRoutes('peer-b')?.routeHints, ['/memory/peer-b/p2p/peer-b']);
+    assert.deepEqual(memberC.resolveRoutes('peer-b')?.coordinationRelays, ['/memory/relay/peer-b']);
+    await memberC.prepareRoutes('peer-b', AbortSignal.timeout(4_000));
+    assert.deepEqual(memberC.resolveRoutes('peer-b')?.routeHints, [movedRoute]);
+    assert.deepEqual(memberC.resolveRoutes('peer-b')?.coordinationRelays, [movedRelay]);
+  } finally {
+    await Promise.allSettled([authority.close(), memberB.close(), memberC.close()]);
+    await Promise.allSettled([
+      authorityPeer.close(),
+      memberBPeer.close(),
+      memberCPeer.close(),
+      ...serving,
+    ]);
+    await rm(root, { recursive: true, force: true });
+  }
+});
+
 test('reconciles one selected Mesh into signed transit routes and native policy', async () => {
   const root = await mkdtemp(join(tmpdir(), 'maka-peer-mesh-transit-'));
   const network = new MemoryPeerNetwork();
@@ -887,6 +938,7 @@ class MemoryPeerClient implements PeerMeshTransport {
   #responseDelayMs = 0;
   #reachable = true;
   #routeHints: readonly string[];
+  #coordinationRelays: readonly string[];
   #nextConnectionBarrier:
     | {
         readonly started: () => void;
@@ -916,13 +968,14 @@ class MemoryPeerClient implements PeerMeshTransport {
     private readonly peers: ReadonlyMap<string, MemoryPeerClient>,
   ) {
     this.#routeHints = [`/memory/${peerId}/p2p/${peerId}`];
+    this.#coordinationRelays = [`/memory/relay/${peerId}`];
   }
 
   identity() {
     return {
       peerId: this.peerId,
       listenAddresses: this.#routeHints,
-      coordinationRelays: [`/memory/relay/${this.peerId}`],
+      coordinationRelays: this.#coordinationRelays,
     } as const;
   }
 
@@ -930,6 +983,10 @@ class MemoryPeerClient implements PeerMeshTransport {
     this.#routeHints = [...routeHints];
   }
 
+  setCoordinationRelays(coordinationRelays: readonly string[]): void {
+    this.#coordinationRelays = [...coordinationRelays];
+  }
+
   setReachable(reachable: boolean): void {
     this.#reachable = reachable;
   }
@@ -1128,6 +1185,29 @@ async function waitForAbortable(task: Promise<void>, signal?: AbortSignal): Prom
   }
 }
 
+async function waitForRoutes(
+  node: PeerMeshNode,
+  peerId: string,
+  expectedRouteHints: readonly string[],
+  expectedCoordinationRelays: readonly string[],
+): Promise<void> {
+  const deadline = Date.now() + 4_000;
+  while (Date.now() < deadline) {
+    const routes = node.resolveRoutes(peerId);
+    if (
+      JSON.stringify(routes?.routeHints) === JSON.stringify(expectedRouteHints) &&
+      JSON.stringify(routes?.coordinationRelays) === JSON.stringify(expectedCoordinationRelays)
+    )
+      return;
+    await delay(25);
+  }
+  assert.deepEqual(node.resolveRoutes(peerId), {
+    routeHints: expectedRouteHints,
+    coordinationRelays: expectedCoordinationRelays,
+    transitRelayPeerIds: [],
+  });
+}
+
 function memorySignature(peerId: string, payload: Buffer): Buffer {
   return createHash('sha256').update(peerId).update(payload).digest();
 }
diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts
index ce5627cf97..0cfb70336c 100644
--- a/packages/runtime-host/src/__tests__/peer-native.test.ts
+++ b/packages/runtime-host/src/__tests__/peer-native.test.ts
@@ -101,6 +101,10 @@ module.exports = {
     );
     let routesPrepared = false;
     const preparedPeerIds: string[] = [];
+    let releaseSelfContainedPreparation!: () => void;
+    const selfContainedPreparation = new Promise<void>((resolve) => {
+      releaseSelfContainedPreparation = resolve;
+    });
     const client = createRuntimeHostPeerClient({
       nativePath,
       keyPath: join(directory, 'peer.key'),
@@ -109,6 +113,7 @@ module.exports = {
           preparedPeerIds.push(peerId);
           routesPrepared = true;
           if (peerId === 'fallback') throw new Error('Mesh refresh failed');
+          if (peerId === 'self-contained') await selfContainedPreparation;
         },
         resolveRoutes: () =>
           routesPrepared
@@ -153,11 +158,14 @@ module.exports = {
     await assert.rejects(client.connect(peerConnectInput('unreachable')), (failure: unknown) => {
       return failure instanceof RuntimeHostPeerError && failure.code === 'transit_unavailable';
     });
-    await client.connect({
+    const selfContained = client.connect({
       ...peerConnectInput('self-contained'),
       coordinationRelays: ['/memory/explicit-relay'],
     });
-    assert.equal(preparedPeerIds.includes('self-contained'), false);
+    await waitForRequestCount(native.default.stats, 7);
+    assert.equal(preparedPeerIds.includes('self-contained'), true);
+    releaseSelfContainedPreparation();
+    await selfContained;
     assert.deepEqual(native.default.stats, {
       starts: 1,
       closes: 0,
diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
index 6791d4d51f..b3c68dc297 100644
--- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
+++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts
@@ -1275,9 +1275,40 @@ test('close stops transcript pagination after the in-flight page', async () => {
   assert.equal(pageRequests, 1);
 });
 
+test('probes an otherwise idle accepted Runtime Host connection', { timeout: 2_000 }, async () => {
+  const probed = deferred<void>();
+  await withProtocolPeer(
+    async (transport, hostEpoch, rootId) => {
+      const hello = decodeClientFrame(await transport.read(1_000));
+      assert.ok('kind' in hello && hello.kind === 'hello');
+      await writeProtocolFrame(transport, {
+        kind: 'accepted',
+        rootId,
+        hostEpoch,
+        connectionId: 'connection-idle-liveness',
+        selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION,
+        compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
+        compositionId: 'maka.interactive',
+        compositionRevision: '1',
+        state: 'ready',
+      });
+      await answerStatus(transport, hostEpoch);
+    },
+    async () => probed.promise,
+    {
+      livenessIntervalMs: 20,
+      onLivenessProbe: probed.resolve,
+    },
+  );
+});
+
 async function withProtocolPeer(
   serve: (transport: FramedTransport, hostEpoch: string, rootId: string) => Promise<void>,
   run: (connection: RuntimeHostConnection) => Promise<void>,
+  connectionOptions: {
+    readonly livenessIntervalMs?: number;
+    readonly onLivenessProbe?: () => void;
+  } = {},
 ): Promise<void> {
   const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-subscription-'));
   const capability = await resolveStorageRoot({
@@ -1318,6 +1349,7 @@ async function withProtocolPeer(
     const connected = await connectRuntimeHost({
       rootPath: join(base, 'root'),
       protocol: PROTOCOL,
+      ...connectionOptions,
     });
     assert.equal(connected.kind, 'connected');
     if (connected.kind !== 'connected') return;
diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts
index 4ec10ddae1..7ef861f576 100644
--- a/packages/runtime-host/src/client/connection.ts
+++ b/packages/runtime-host/src/client/connection.ts
@@ -97,9 +97,9 @@ export interface ConnectRuntimeHostInput {
   connectTimeoutMs?: number;
   handshakeTimeoutMs?: number;
   /**
-   * Interval between liveness probes while a domain request is outstanding.
-   * Injectable so tests exercise requests that outlive a probe cycle without
-   * waiting the real cadence; defaults to DEFAULT_LIVENESS_INTERVAL_MS (2s).
+   * Maximum quiet interval before an end-to-end Host liveness probe.
+   * Any valid inbound frame restarts the interval. Injectable so tests can
+   * exercise the cadence without waiting the real default (2s).
    */
   livenessIntervalMs?: number;
   /**
@@ -419,6 +419,7 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
       onFailure: (error) => this.#fail(error),
     });
     void this.#readResponses();
+    this.#scheduleLivenessCheck();
   }
 
   request<K extends DirectRequestOperationKey>(
@@ -800,16 +801,7 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
   }
 
   #scheduleLivenessCheck(): void {
-    if (
-      this.#terminalError ||
-      this.#livenessTimer ||
-      this.#livenessProbePending ||
-      !this.#hasOutstandingDomainRequest()
-    ) {
-      if (!this.#hasOutstandingDomainRequest() && this.#livenessTimer) {
-        clearTimeout(this.#livenessTimer);
-        this.#livenessTimer = undefined;
-      }
+    if (this.#terminalError || this.#livenessTimer || this.#livenessProbePending) {
       return;
     }
     this.#livenessTimer = setTimeout(() => {
@@ -818,18 +810,8 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection {
     }, this.#livenessIntervalMs);
   }
 
-  #hasOutstandingDomainRequest(): boolean {
-    if (this.#retiredRequests.size > 0) return true;
-    for (const pending of this.#pendingRequests.values()) {
-      if (pending.operation !== 'host.status') return true;
-    }
-    return false;
-  }
-
   #startLivenessProbe(): void {
-    if (this.#terminalError || this.#livenessProbePending || !this.#hasOutstandingDomainRequest()) {
-      return;
-    }
+    if (this.#terminalError || this.#livenessProbePending) return;
     this.#livenessProbePending = true;
     void this.#requestOperation(
       'host.status',
diff --git a/packages/runtime-host/src/client/peer-client.ts b/packages/runtime-host/src/client/peer-client.ts
index e8f639141f..5e3d86abe9 100644
--- a/packages/runtime-host/src/client/peer-client.ts
+++ b/packages/runtime-host/src/client/peer-client.ts
@@ -216,6 +216,16 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     onPhase?: (phase: RuntimeHostPeerConnectionPhase) => void,
   ): Promise<RuntimeHostPeerNativeStream> {
     notifyPhase(onPhase, 'discovering');
+    if (input.routeHints.length > 0 && (input.coordinationRelays?.length ?? 0) > 0) {
+      // Reserve the application-connect lane before refreshing Mesh control.
+      // A fresh invitation therefore dials immediately, while a persisted
+      // invitation still refreshes routes for the next reconnect if its
+      // advertised reservation has rotated.
+      notifyPhase(onPhase, 'connecting');
+      const connection = this.#connect(input, signal, 'application');
+      void this.#prepareRoutes(input, signal).catch(() => undefined);
+      return connection;
+    }
     await this.#prepareRoutes(input, signal);
     notifyPhase(onPhase, 'connecting');
     return this.#connect(input, signal, 'application');
@@ -226,10 +236,6 @@ class RuntimeHostPeerClientImpl implements RuntimeHostPeerClient {
     signal: AbortSignal | undefined,
   ): Promise<void> {
     if (!this.#routeResolver?.prepareRoutes) return;
-    // A freshly issued target with both direct hints and coordination relays is
-    // already self-contained. Cached Mesh state can still enrich the actual
-    // dial below, but a stale control plane must not delay this explicit path.
-    if (input.routeHints.length > 0 && (input.coordinationRelays?.length ?? 0) > 0) return;
     const deadline = AbortSignal.timeout(Math.min(10_000, input.directDeadlineMs));
     const operationSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
     try {
diff --git a/packages/runtime-host/src/peer-mesh/node.ts b/packages/runtime-host/src/peer-mesh/node.ts
index 2e16e5469c..be4c224b46 100644
--- a/packages/runtime-host/src/peer-mesh/node.ts
+++ b/packages/runtime-host/src/peer-mesh/node.ts
@@ -76,6 +76,7 @@ const ROUTE_PAGE_SIZE = 8;
 const RECONCILE_CONCURRENCY = 4;
 const RECONCILE_DEADLINE_MS = 60 * 1_000;
 const RECONCILE_INTERVAL_MS = 30 * 1_000;
+const LOCAL_ROUTE_OBSERVATION_INTERVAL_MS = 1_000;
 
 interface RedeemInvitationRequest {
   readonly kind: 'redeem-invitation';
@@ -852,11 +853,11 @@ class PeerMeshNodeImpl implements PeerMeshNode {
         isActiveMembership(mesh, localPeerId) && mesh.roster.roster.members.includes(peerId),
     );
     if (!visible) return;
-    if (
-      stored.routes.some(({ route }) => route.peerId === peerId && route.expiresAt > this.#now())
-    ) {
-      return;
-    }
+    // A signed route can remain within its TTL after a peer restarted or
+    // rotated Relay reservations. Every connection establishment therefore
+    // asks the Mesh control plane for its newest record. Callers with a
+    // self-contained invitation run this reconciliation in parallel with the
+    // first dial; callers without usable routes wait for it.
     await this.reconcile(signal);
   }
 
@@ -929,10 +930,32 @@ class PeerMeshNodeImpl implements PeerMeshNode {
           }
         }
       }
-      await delay(RECONCILE_INTERVAL_MS, undefined, { signal }).catch(() => undefined);
+      await this.#waitForReconciliationTrigger(signal).catch(() => undefined);
     }
   }
 
+  async #waitForReconciliationTrigger(signal: AbortSignal): Promise<void> {
+    let remainingMs = RECONCILE_INTERVAL_MS;
+    while (!signal.aborted && remainingMs > 0) {
+      const waitMs = Math.min(LOCAL_ROUTE_OBSERVATION_INTERVAL_MS, remainingMs);
+      await delay(waitMs, undefined, { signal });
+      if (this.#localRouteRequiresRefresh()) return;
+      remainingMs -= waitMs;
+    }
+  }
+
+  #localRouteRequiresRefresh(): boolean {
+    const identity = this.#peer.identity();
+    const current = this.#store.read();
+    if (!current.meshes.some((state) => isActiveMembership(state, identity.peerId))) {
+      return false;
+    }
+    const existing = current.routes
+      .filter(({ route }) => route.peerId === identity.peerId)
+      .sort((left, right) => right.route.sequence - left.route.sequence)[0];
+    return !isCurrentLocalRoute(existing, identity, current, this.#endpointKind, this.#now());
+  }
+
   async #reconcile(signal?: AbortSignal): Promise<void> {
     const lifetimeSignal = signal
       ? AbortSignal.any([signal, this.#lifetime.signal])
@@ -1174,15 +1197,7 @@ class PeerMeshNodeImpl implements PeerMeshNode {
       const existing = current.routes
         .filter(({ route }) => route.peerId === identity.peerId)
         .sort((left, right) => right.route.sequence - left.route.sequence)[0];
-      if (
-        existing &&
-        existing.route.expiresAt > now + ROUTE_REFRESH_LEAD_MS &&
-        sameAddresses(existing.route.routeHints, identity.listenAddresses) &&
-        sameAddresses(existing.route.coordinationRelays, identity.coordinationRelays) &&
-        existing.route.endpointKind === this.#endpointKind &&
-        existing.route.displayName === (current.displayName ?? undefined) &&
-        existing.route.transitMeshId === current.transitMeshId
-      ) {
+      if (isCurrentLocalRoute(existing, identity, current, this.#endpointKind, now)) {
         return { state: current, result: existing };
       }
       const route = await this.#signLocalRoute(current);
@@ -2141,6 +2156,24 @@ function sameAddresses(left: readonly string[], right: readonly string[]): boole
   return left.length === right.length && left.every((address, index) => address === right[index]);
 }
 
+function isCurrentLocalRoute(
+  existing: SignedPeerMeshRouteRecordV1 | undefined,
+  identity: ReturnType<PeerMeshTransport['identity']>,
+  current: Pick<PeerMeshStoredStateV1, 'displayName' | 'transitMeshId'>,
+  endpointKind: 'client' | 'host' | undefined,
+  now: number,
+): existing is SignedPeerMeshRouteRecordV1 {
+  return Boolean(
+    existing &&
+      existing.route.expiresAt > now + ROUTE_REFRESH_LEAD_MS &&
+      sameAddresses(existing.route.routeHints, identity.listenAddresses) &&
+      sameAddresses(existing.route.coordinationRelays, identity.coordinationRelays) &&
+      existing.route.endpointKind === endpointKind &&
+      existing.route.displayName === (current.displayName ?? undefined) &&
+      existing.route.transitMeshId === current.transitMeshId,
+  );
+}
+
 function mergeAddresses(
   primary: readonly string[],
   fallback: readonly string[],


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


(maka) 02/11: fix(peer): expose live relay routes to shared sessions

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

M4n5ter pushed a commit to branch fix/session-share-peer-route-liveness
in repository https://gitbox.apache.org/repos/asf/maka.git

commit b372932be0ce701f11c57e697bc6eb9936cb4c86
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 1 16:41:06 2026 +0800

    fix(peer): expose live relay routes to shared sessions
---
 .../runtime-host-collaboration-ipc-main.test.ts    | 38 ++++++++++++-
 .../main/runtime-host-collaboration-ipc-main.ts    |  7 +++
 apps/desktop/src/preload/bridge-contract.d.ts      |  9 +++-
 .../renderer/locales/session-collaboration-copy.ts | 12 +++++
 .../src/renderer/session-collaboration-dialog.tsx  | 24 ++++++++-
 .../__tests__/execution-service-listeners.test.ts  | 63 ++++++++++++++++++++++
 .../runtime-host/src/server/execution-service.ts   | 10 +++-
 7 files changed, 156 insertions(+), 7 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
index 2863fec8e2..3596921c8f 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts
@@ -29,7 +29,7 @@ import { registerRuntimeHostCollaborationIpc } from '../runtime-host-collaborati
 
 const ROOT_ID = 'a'.repeat(64);
 
-test('requires Owner confirmation before issuing a plaintext collaboration invitation', async () => {
+test('requires plaintext confirmation and reports the issued invitation routes', async () => {
   const handlers = new Map<string, IpcHandler>();
   const ipcMain: ReconnectableReadIpcMain = {
     handle(channel, listener) {
@@ -92,9 +92,43 @@ test('requires Owner confirmation before issuing a plaintext collaboration invit
   assert.equal(prepareCalls, 1);
   assert.equal((result as { kind?: unknown }).kind, 'prepared');
   const invitation = (result as {
-    invitation: { invitationCode: string };
+    invitation: { invitationCode: string; connectivity: unknown };
   }).invitation;
+  assert.deepEqual(invitation.connectivity, { kind: 'configured' });
   const bundle = decodeDesktopCollaborationInvitation(invitation.invitationCode);
   assert.equal(decodeCollaborationInvitationCode(bundle.invitationCode).rootId, ROOT_ID);
   assert.equal(bundle.target.transport.kind, 'plaintext');
+
+  const peerHandlers = new Map<string, IpcHandler>();
+  registerRuntimeHostCollaborationIpc(
+    client as unknown as Parameters<typeof registerRuntimeHostCollaborationIpc>[0],
+    {
+      handle(channel, listener) {
+        peerHandlers.set(channel, listener);
+      },
+    },
+    async () => ({
+      name: 'Peer Lab',
+      transport: {
+        kind: 'libp2p-direct',
+        peerId: '12D3KooWpeer',
+        routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+        coordinationRelays: [
+          '/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay',
+        ],
+      },
+    }),
+  );
+  const preparePeer = peerHandlers.get('session-collaboration:prepare');
+  assert.ok(preparePeer);
+  const peerResult = await preparePeer(
+    {} as Parameters<IpcHandler>[0],
+    'session-1',
+    'observe',
+    false,
+  );
+  assert.deepEqual(
+    (peerResult as { invitation: { connectivity: unknown } }).invitation.connectivity,
+    { kind: 'peer', coordinationRelayCount: 1 },
+  );
 });
diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
index 622cb75c7a..9c1ae80b7b 100644
--- a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
@@ -70,6 +70,13 @@ export function registerRuntimeHostCollaborationIpc(
             invitationCode: prepared.invitationCode,
             target,
           }),
+          connectivity:
+            target.transport.kind === 'libp2p-direct'
+              ? {
+                  kind: 'peer' as const,
+                  coordinationRelayCount: target.transport.coordinationRelays.length,
+                }
+              : { kind: 'configured' as const },
         },
       };
     },
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 95f2c2a3bf..8f89efd7cb 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -345,7 +345,14 @@ export type DesktopSessionCollaborationCancelResult = SessionCollaborationCancel
 export type DesktopSessionCollaborationPrepareResult =
   | {
       readonly kind: 'prepared';
-      readonly invitation: CollaborationInvitationPrepareResult;
+      readonly invitation: CollaborationInvitationPrepareResult & {
+        readonly connectivity:
+          | {
+              readonly kind: 'peer';
+              readonly coordinationRelayCount: number;
+            }
+          | { readonly kind: 'configured' };
+      };
     }
   | { readonly kind: 'insecure_confirmation_required' };
 
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
index 1ba3fe444d..9b0cf33a53 100644
--- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -37,6 +37,12 @@ const ZH = {
     createInvitation: '创建邀请',
     invitationCode: '一次性邀请码',
     invitationHelp: '邀请码包含连接地址和访客凭据,不包含所有者凭据。',
+    coordinationReady: '已包含跨网络协调路径',
+    coordinationReadyBody:
+      'Runtime Host 已取得 Relay reservation;这能提高不同网络中的设备成功建立连接的机会。',
+    coordinationUnavailable: '尚未取得 Relay reservation',
+    coordinationUnavailableBody:
+      '这枚邀请码当前只包含直接路径,不同网络中的设备可能无法连接。可稍后撤销并重新创建。',
     copy: '复制邀请码',
     copied: '邀请码已复制',
     close: '完成',
@@ -109,6 +115,12 @@ const EN = {
     createInvitation: 'Create invitation',
     invitationCode: 'One-time invitation code',
     invitationHelp: 'The code contains the connection address and Guest credential, never the Owner credential.',
+    coordinationReady: 'Cross-network coordination is included',
+    coordinationReadyBody:
+      'The Runtime Host has obtained a Relay reservation, improving the chance that devices on different networks can connect.',
+    coordinationUnavailable: 'No Relay reservation yet',
+    coordinationUnavailableBody:
+      'This invitation currently contains direct routes only, so devices on different networks may not connect. Revoke and recreate it later.',
     copy: 'Copy invitation',
     copied: 'Invitation copied',
     close: 'Done',
diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
index a207762c5f..7f17533cf3 100644
--- a/apps/desktop/src/renderer/session-collaboration-dialog.tsx
+++ b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
@@ -32,10 +32,10 @@ import {
 } from '@maka/ui';
 import type {
   CollaborationAccessQueryResult,
-  CollaborationInvitationPrepareResult,
   SessionCollaborationGrant,
   SessionTurnAccessRequest,
 } from '@maka/runtime-host/protocol';
+import type { DesktopSessionCollaborationPrepareResult } from '../preload/bridge-contract.js';
 import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js';
 import { turnRequestStateLabel } from './session-turn-request-composer.js';
 
@@ -54,6 +54,11 @@ type CollaborationAuthorityState =
   | 'remote_access_off'
   | 'unavailable';
 
+type PreparedInvitation = Extract<
+  DesktopSessionCollaborationPrepareResult,
+  { readonly kind: 'prepared' }
+>['invitation'];
+
 export function SessionCollaborationDialog(props: Props) {
   return <ShareSessionDialog {...props} />;
 }
@@ -63,7 +68,7 @@ function ShareSessionDialog(props: Props) {
   const toast = useToast();
   const [preset, setPreset] = useState<'observe' | 'request_turn'>('observe');
   const [access, setAccess] = useState<CollaborationAccessQueryResult>();
-  const [invitation, setInvitation] = useState<CollaborationInvitationPrepareResult>();
+  const [invitation, setInvitation] = useState<PreparedInvitation>();
   const [turnRequests, setTurnRequests] = useState<readonly SessionTurnAccessRequest[]>();
   const [authorityState, setAuthorityState] = useState<CollaborationAuthorityState>('loading');
   const [working, setWorking] = useState(false);
@@ -269,6 +274,21 @@ function ShareSessionDialog(props: Props) {
                     onChange={() => undefined}
                   />
                   <Text type="supporting" color="secondary">{copy.invitationHelp}</Text>
+                  {invitation.connectivity.kind === 'peer' ? (
+                    invitation.connectivity.coordinationRelayCount > 0 ? (
+                      <Banner
+                        status="success"
+                        title={copy.coordinationReady}
+                        description={copy.coordinationReadyBody}
+                      />
+                    ) : (
+                      <Banner
+                        status="warning"
+                        title={copy.coordinationUnavailable}
+                        description={copy.coordinationUnavailableBody}
+                      />
+                    )
+                  ) : null}
                   <Button variant="secondary" label={copy.copy} onClick={() => void copyInvitation()} />
                 </FormLayout>
               ) : (
diff --git a/packages/runtime-host/src/__tests__/execution-service-listeners.test.ts b/packages/runtime-host/src/__tests__/execution-service-listeners.test.ts
new file mode 100644
index 0000000000..84a597b727
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/execution-service-listeners.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 assert from 'node:assert/strict';
+import test from 'node:test';
+import type { RuntimeHostPeerMeshOwner } from '../peer-mesh/owner.js';
+import { attachPeerOwnerCleanup } from '../server/execution-service.js';
+import type { RuntimeHostListenerSet } from '../server/listener-set.js';
+
+test('the execution service exposes relay reservations discovered after startup', async () => {
+  let coordinationRelays: readonly string[] = [];
+  let listenersClosed = false;
+  let ownerClosed = false;
+  const listeners: RuntimeHostListenerSet = {
+    listeners: [],
+    localEndpoint: 'local',
+    websocketEndpoints: [],
+    get peerListeners() {
+      return [
+        {
+          peerId: '12D3KooWpeer',
+          listenAddresses: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
+          coordinationRelays,
+        },
+      ];
+    },
+    async closeAdmission() {},
+    async cleanup() {
+      listenersClosed = true;
+    },
+  };
+  const owner = {
+    async close() {
+      ownerClosed = true;
+    },
+  } as unknown as RuntimeHostPeerMeshOwner;
+
+  const attached = attachPeerOwnerCleanup(listeners, owner);
+  assert.deepEqual(attached.peerListeners[0]?.coordinationRelays, []);
+
+  coordinationRelays = ['/dns4/relay.example/udp/443/quic-v1/p2p/12D3KooWrelay'];
+  assert.deepEqual(attached.peerListeners[0]?.coordinationRelays, coordinationRelays);
+
+  await attached.cleanup();
+  assert.equal(listenersClosed, true);
+  assert.equal(ownerClosed, true);
+});
diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts
index baf69cdbc4..2ed15c3f80 100644
--- a/packages/runtime-host/src/server/execution-service.ts
+++ b/packages/runtime-host/src/server/execution-service.ts
@@ -163,12 +163,18 @@ export async function startExecutionRuntimeHostService(
   }
 }
 
-function attachPeerOwnerCleanup(
+export function attachPeerOwnerCleanup(
   listeners: RuntimeHostListenerSet,
   owner: RuntimeHostPeerMeshOwner,
 ): RuntimeHostListenerSet {
   return {
-    ...listeners,
+    listeners: listeners.listeners,
+    localEndpoint: listeners.localEndpoint,
+    websocketEndpoints: listeners.websocketEndpoints,
+    get peerListeners() {
+      return listeners.peerListeners;
+    },
+    closeAdmission: () => listeners.closeAdmission(),
     cleanup: async () => {
       const errors: unknown[] = [];
       await listeners.cleanup().catch((error: unknown) => errors.push(error));


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