You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/20 19:08:34 UTC

(maka) branch feat/5488-ui-first-startup created (now 1c79232924)

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

Astro-Han pushed a change to branch feat/5488-ui-first-startup
in repository https://gitbox.apache.org/repos/asf/maka.git


      at 1c79232924 fix(desktop): keep revision send alive past a slow transcript open

This branch includes the following new commits:

     new 1c79232924 fix(desktop): keep revision send alive past a slow transcript open

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



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


(maka) 01/01: fix(desktop): keep revision send alive past a slow transcript open

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

Astro-Han pushed a commit to branch feat/5488-ui-first-startup
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 1c79232924e70a2b213031c81c0ed7bc15dcf553
Author: AstroHan <le...@outlook.com>
AuthorDate: Mon Sep 21 03:08:20 2026 +0800

    fix(desktop): keep revision send alive past a slow transcript open
    
    prepareRevisionSend read the revised Session's transcript with a 480ms
    budget that also covers transcripts.open, which waits on Host admission
    and legitimately takes far longer under load. When it lapsed, the catch
    ran rollback — whose navigation bumps the selection revision — and only
    then checked selectionIsCurrent, which was therefore always stale, so
    the failure was discarded without a toast and the send vanished.
    
    A transcript replica that is still opening must not gate the send: the
    Session view fills from its own consumer and the write boundary is Host
    admission. tolerateOpenTimeout reports such an open as unsettled instead
    of failing; real open errors and caller aborts still propagate. Failure
    feedback now runs before rollback so rollback's own navigation cannot
    invalidate it.
    
    Generated-by: Devin
---
 apps/desktop/renderer-architecture.json            |   2 +-
 .../__tests__/app-shell-revision-actions.test.ts   | 110 +++++++++++++++++++--
 .../desktop-transcript-range-store.test.ts         |  40 ++++++++
 .../src/renderer/app-shell-revision-actions.ts     |  36 ++++---
 .../platform/desktop/session-message-settlement.ts |  22 ++++-
 5 files changed, 186 insertions(+), 24 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 06419834d1..57bb5dec59 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -580,7 +580,7 @@
           "@maka/core/session": 1
         },
         "importSpecifiers": 8,
-        "nonTriviaTokens": 2155
+        "nonTriviaTokens": 2154
       },
       "src/renderer/app-shell-session-events.ts": {
         "importDeclarations": 2,
diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts
index d7f35be7f7..0bfe8a019b 100644
--- a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts
@@ -23,6 +23,9 @@ import { describe, it } from 'node:test';
 import type { StoredMessage } from '@maka/core/session';
 import { createAppShellRevisionActions } from '../../renderer/app-shell-revision-actions.js';
 
+const SESSION_1 = JSON.stringify(['host-1', 'session-1']);
+const SESSION_2 = JSON.stringify(['host-1', 'session-2']);
+
 function userMessage(turnId: string, text: string, extra: Record<string, unknown> = {}): StoredMessage {
   return {
     id: `msg-${turnId}`,
@@ -36,11 +39,19 @@ function userMessage(turnId: string, text: string, extra: Record<string, unknown
 
 function createActions(input: { messages: StoredMessage[] }) {
   const drafts: unknown[] = [];
+  const errors: string[] = [];
+  const infos: string[] = [];
   let composerText = '';
+  let selectionRevision = 0;
+  const activeIdRef: { current: string | undefined } = { current: SESSION_1 };
   const revisionDraftRef: { current: unknown } = { current: null };
   const actions = createAppShellRevisionActions({
     uiLocale: 'en' as never,
-    activeIdRef: { current: 'session-1' },
+    activeIdRef,
+    captureSelection: () => {
+      const revision = selectionRevision;
+      return () => selectionRevision === revision;
+    },
     composerRef: {
       current: {
         getText: () => composerText,
@@ -56,8 +67,10 @@ function createActions(input: { messages: StoredMessage[] }) {
     },
     messages: input.messages,
     hasPendingAttachments: () => false,
-    openSessionInChat: () => {},
-    refreshMessages: async () => true,
+    openSessionInChat: (sessionId: string) => {
+      selectionRevision += 1;
+      activeIdRef.current = sessionId;
+    },
     refreshSessions: async () => [],
     setMessages: () => {},
     commitRevisionDraft: (draft: unknown) => {
@@ -66,11 +79,28 @@ function createActions(input: { messages: StoredMessage[] }) {
     },
     revisionDraftRef,
     toastApi: {
-      info: () => {},
-      error: () => {},
+      info: (title: string) => infos.push(title),
+      error: (title: string) => errors.push(title),
     },
   } as never);
-  return Object.assign(actions, { drafts, composerState: { get text(): string { return composerText; } } });
+  return Object.assign(actions, {
+    drafts,
+    errors,
+    infos,
+    activeIdRef,
+    composerState: { get text(): string { return composerText; } },
+  });
+}
+
+async function withWindowMaka(maka: unknown, run: () => Promise<void>): Promise<void> {
+  const target = globalThis as { window?: unknown };
+  const previous = target.window;
+  target.window = { maka };
+  try {
+    await run();
+  } finally {
+    target.window = previous;
+  }
 }
 
 describe('app-shell revision actions with structured context (#5109)', () => {
@@ -120,3 +150,71 @@ describe('app-shell revision actions with structured context (#5109)', () => {
     assert.equal(h.drafts.at(-1), undefined, 'attachment-bearing sources stay explicitly rejected');
   });
 });
+
+describe('prepareRevisionSend transcript settlement', () => {
+  it('keeps the send alive while the revision transcript is still opening', async () => {
+    let abandoned = 0;
+    await withWindowMaka(
+      {
+        sessions: {
+          reviseBeforeTurn: async () => ({ id: SESSION_2 }),
+          abandonSessionCopy: async () => {
+            abandoned += 1;
+          },
+        },
+        transcripts: {
+          open: async (
+            _sessionId: string,
+            _handler: unknown,
+            registerCancellation: (cancel: () => void) => void,
+          ) =>
+            new Promise<never>((_resolve, reject) => {
+              registerCancellation(() => reject(new Error('open cancelled')));
+            }),
+          readTurn: async () => [],
+        },
+      },
+      async () => {
+        const h = createActions({ messages: [userMessage('turn-1', 'original')] });
+        h.beginEditUserMessage('turn-1');
+        assert.equal(await h.prepareRevisionSend('edited'), true);
+        assert.equal(abandoned, 0, 'a lagging replica must not roll back the revision');
+        assert.equal(h.activeIdRef.current, SESSION_2);
+        assert.deepEqual(h.errors, []);
+        assert.equal(
+          (h.drafts.at(-1) as { draftSessionId?: string }).draftSessionId,
+          SESSION_2,
+        );
+      },
+    );
+  });
+
+  it('surfaces a failed preparation instead of swallowing it behind rollback', async () => {
+    let abandoned = 0;
+    await withWindowMaka(
+      {
+        sessions: {
+          reviseBeforeTurn: async () => ({ id: SESSION_2 }),
+          abandonSessionCopy: async () => {
+            abandoned += 1;
+          },
+        },
+        transcripts: {
+          open: async () => {
+            throw new Error('Host lost the Session');
+          },
+          readTurn: async () => [],
+        },
+      },
+      async () => {
+        const h = createActions({ messages: [userMessage('turn-1', 'original')] });
+        h.beginEditUserMessage('turn-1');
+        assert.equal(await h.prepareRevisionSend('edited'), false);
+        assert.equal(h.errors.length, 1, 'the failure must reach the user before rollback navigates away');
+        assert.equal(abandoned, 1);
+        assert.equal(h.activeIdRef.current, SESSION_1);
+        assert.equal(h.composerState.text, 'edited');
+      },
+    );
+  });
+});
diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
index e4281aefac..e7729bcd79 100644
--- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
+++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts
@@ -108,6 +108,46 @@ test('cancels settlement while transcript open is pending', async () => {
   }
 });
 
+test('reports an unopened transcript as unsettled when the caller tolerates it', async () => {
+  let cancelRequested = false;
+  const result = await readSettledMessagesFrom(
+    {
+      transcripts: {
+        readTurn: async () => [],
+        open: async (_sessionId, _handler, registerCancellation) =>
+          new Promise<never>((_resolve, reject) => {
+            registerCancellation?.(() => {
+              cancelRequested = true;
+              reject(new Error('open cancelled'));
+            });
+          }),
+      },
+    },
+    JSON.stringify(['host-1', 'session-1']),
+    { tolerateOpenTimeout: true },
+  );
+  assert.deepEqual(result, { messages: [], settled: false });
+  assert.equal(cancelRequested, true);
+});
+
+test('an unopened transcript still fails settlement by default', async () => {
+  await assert.rejects(
+    readSettledMessagesFrom(
+      {
+        transcripts: {
+          readTurn: async () => [],
+          open: async (_sessionId, _handler, registerCancellation) =>
+            new Promise<never>((_resolve, reject) => {
+              registerCancellation?.(() => reject(new Error('open cancelled')));
+            }),
+        },
+      },
+      JSON.stringify(['host-1', 'session-1']),
+    ),
+    /timed out while opening/,
+  );
+});
+
 test('reads one Host-owned Turn outside the bounded transcript tail', async () => {
   const sessionKey = JSON.stringify(['host-1', 'session-1']);
   const turnB: StoredMessage[] = [
diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts
index 9325d66de7..76e0650ecc 100644
--- a/apps/desktop/src/renderer/app-shell-revision-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts
@@ -318,10 +318,13 @@ export function createAppShellRevisionActions(deps: {
       commitRevisionDraft(prepared);
       openSessionInChat(newSession.id);
       selectionIsCurrent = captureSelection();
+      // A transcript replica that is still opening must not eat the send: the
+      // Session view already fills from its own consumer, and the write
+      // boundary is Host admission, not read readiness.
       const { messages: preparedMessages, settled } = await readSettledMessages(newSession.id, {
         signal: preparationAbort.signal,
+        tolerateOpenTimeout: true,
       });
-      if (!settled) throw new Error('Revised Session transcript did not become ready');
       if (
         !selectionIsCurrent() || activeIdRef.current !== newSession.id ||
         revisionDraftRef.current !== prepared
@@ -329,29 +332,32 @@ export function createAppShellRevisionActions(deps: {
         await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent);
         return false;
       }
-      setMessages(preparedMessages);
+      if (settled) setMessages(preparedMessages);
       composerRef.current?.focus();
       toastApi.info(copy.revisionReadyTitle, copy.revisionReadyDescription);
       await refreshSessions();
       return true;
     } catch (error) {
       if (preparationAbort.signal.aborted) return false;
+      // Rollback itself navigates back to the source Session, so the failure
+      // must be surfaced before it runs — checking after it is always stale.
+      if (selectionIsCurrent()) {
+        if (isSessionWorkspaceUnavailableError(error)) {
+          showSessionWorkspaceUnavailableToast(toastApi, uiLocale, {
+            sessionId: sourceSessionId,
+          });
+        } else {
+          toastApi.error(
+            copy.operationFailedTitle,
+            localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
+            undefined,
+            { sessionId: sourceSessionId },
+          );
+        }
+      }
       if (preparedSessionId) {
         await rollbackPreparedRevision(startedDraft, preparedSessionId, text, selectionIsCurrent);
       }
-      if (!selectionIsCurrent()) return false;
-      if (isSessionWorkspaceUnavailableError(error)) {
-        showSessionWorkspaceUnavailableToast(toastApi, uiLocale, {
-          sessionId: sourceSessionId,
-        });
-      } else {
-        toastApi.error(
-          copy.operationFailedTitle,
-          localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale),
-          undefined,
-          { sessionId: sourceSessionId },
-        );
-      }
       return false;
     } finally {
       if (revisionPreparationAbort === preparationAbort) revisionPreparationAbort = undefined;
diff --git a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts
index 7508a2babc..856e76d178 100644
--- a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts
+++ b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts
@@ -27,6 +27,11 @@ export interface RefreshMessagesOptions {
   requiredAssistantMessageId?: string;
   requiredTurnId?: string;
   signal?: AbortSignal;
+  /**
+   * A transcript open that outlives the deadline reports unsettled instead of
+   * failing, for callers whose work does not depend on the replica.
+   */
+  tolerateOpenTimeout?: boolean;
 }
 
 export type TranscriptSettlementSource = {
@@ -69,8 +74,16 @@ export async function readSettledMessagesFrom(
   const abort = () => cancel(new Error('Desktop transcript settlement was cancelled'));
   options.signal?.addEventListener('abort', abort, { once: true });
   if (options.signal?.aborted) abort();
+  let openLapsed = false;
   const openTimeout = globalThis.setTimeout(
-    () => cancel(new Error('Desktop transcript settlement timed out while opening')),
+    () => {
+      if (options.tolerateOpenTimeout) {
+        openLapsed = true;
+        cancelOpen();
+      } else {
+        cancel(new Error('Desktop transcript settlement timed out while opening'));
+      }
+    },
     Math.max(0, deadline - Date.now()),
   );
   const opening = source.transcripts.open(
@@ -90,7 +103,12 @@ export async function readSettledMessagesFrom(
   void opening.catch(() => undefined);
   let handle: Awaited<typeof opening> | undefined;
   try {
-    handle = await Promise.race([opening, cancellation]);
+    try {
+      handle = await Promise.race([opening, cancellation]);
+    } catch (error) {
+      if (openLapsed) return { messages: [], settled: false };
+      throw error;
+    }
     globalThis.clearTimeout(openTimeout);
     const requiredTurnId = options.requiredTurnId;
     /*


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