Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/desktop/src/api/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isMissingRestEndpoint } from '@/lib/gateway-rpc'
import { maybeBackfillLegacySessionOwners } from '@/lib/legacy-session-owner-backfill'
import { stampRowsWithOwningConnection } from '@/lib/session-owner-stamp'
import { recordTranscriptTail } from '@/store/transcript-tail'
import type {
Expand Down Expand Up @@ -42,6 +43,12 @@ function sessionScopeQuery(scope?: ProfileScope): string {
* write shape for connection_id on backend-returned rows.
*/
function stampActiveConnectionOwner(sessions: SessionInfo[]): SessionInfo[] {
// Durable half of the same ownership contract (#94724): enumeration under
// registry topology triggers the one-shot server-side owner backfill for
// the serving store when its owner is a single match. Fire-and-forget;
// idempotent server-side; never blocks or fails the list that triggered it.
maybeBackfillLegacySessionOwners()

return stampRowsWithOwningConnection(sessions, getApiRequestConnection())
}

Expand Down Expand Up @@ -417,6 +424,43 @@ export function getLatestSessionMessages(id: string, profile?: ProfileScope): Pr
})
}

/**
* READ-ONLY stored-transcript lookup that never routes a live session
* (#94724 no-owner recovery). Tries the ambient/primary store first, then
* probes every registered NON-local connection by id — a REST read of a
* backend's own state.db is side-effect free (a miss is a plain 404, no
* session is minted or resumed anywhere), so probing across backends is safe
* where live routing would be a guess. Returns null when no reachable
* backend holds the transcript.
*/
export async function fetchStoredTranscriptAcrossBackends(id: string): Promise<SessionMessagesResponse | null> {
try {
return await getLatestSessionMessages(id)
} catch {
// Not on the ambient store — probe the registered backends below.
}

const { $connectionsRegistry } = await import('@/store/connection-registry-state')

const connections = ($connectionsRegistry.get()?.connections ?? []) as Array<{ id?: string }>

for (const connection of connections) {
const connectionId = connection.id?.trim()

if (!connectionId || connectionId === 'local' || connectionId === getApiRequestConnection()) {
continue
}

try {
return await getLatestSessionMessages(id, { connectionId, profile: 'default' })
} catch {
// Not on this backend (or it is unreachable); try the next.
}
}

return null
}

/**
* One page of messages OLDER than the `offset` newest rows.
*
Expand Down
88 changes: 77 additions & 11 deletions apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { useEffect } from 'react'

import { getLatestSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { fetchStoredTranscriptAcrossBackends, getLatestSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { translateNow } from '@/i18n/runtime'
import { toChatMessages } from '@/lib/chat-messages'
import { notify } from '@/store/notifications'
import { isReadOnlyRuntimeId, readOnlyRuntimeIdFor, resumeWithStoredTranscriptFallback } from '@/store/read-only-transcript'
import { knownSessionOwner, ownerLookupSessionRows } from '@/store/session'
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router'
import { publishSessionState, sessionTileOwnerRoute, setSessionTileDelegate } from '@/store/session-states'
import type { SessionResumeResponse } from '@/types/hermes'
Expand Down Expand Up @@ -138,6 +142,11 @@ export function useSessionTileDelegate({
return true
},
interruptSession: async runtimeId => {
// Read-only stored-transcript tiles have no live turn to interrupt.
if (isReadOnlyRuntimeId(runtimeId)) {
return
}

// Same cooldown as the primary chat's Stop (#83855): the gateway may
// still be winding down after this interrupt, so a quick edit/resend
// on the tile must go interrupt-first even though busy already reads
Expand Down Expand Up @@ -193,17 +202,65 @@ export function useSessionTileDelegate({
? { connectionId: owner.connectionId, profile: owner.targetProfile || owner.profile }
: owner

const [prefetch, resumed] = await Promise.all([
getLatestSessionMessages(storedSessionId, restScope).catch(() => null),
singleFlightSessionResume(storedSessionId, () =>
requestForSessionProfile<SessionResumeResponse>(owner, requestGateway, 'session.resume', {
session_id: storedSessionId,
cols: 96,
omit_messages: true,
...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {})
})
const prefetchPromise = getLatestSessionMessages(storedSessionId, restScope).catch(() => null)

// #94724 no-owner recovery: dispatching the resume through the same
// fail-closed gate as the window's RPC dispatcher keeps an unknown
// owner off the ambient socket, and the wrapper opens the stored
// transcript read-only instead of dead-ending the tile — the id-only
// REST read routes no live session at all.
const outcome = await resumeWithStoredTranscriptFallback(
storedSessionId,
() => {
assertSessionOwnerResolved(owner, { method: 'session.resume', sessionId: storedSessionId })

return singleFlightSessionResume(storedSessionId, () =>
requestForSessionProfile<SessionResumeResponse>(owner, requestGateway, 'session.resume', {
session_id: storedSessionId,
cols: 96,
omit_messages: true,
...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {})
})
)
},
async () => {
const stored = (await prefetchPromise) ?? (await fetchStoredTranscriptAcrossBackends(storedSessionId))

if (!stored) {
throw new Error('stored transcript unavailable on every reachable backend')
}

return stored
}
)

const prefetch = await prefetchPromise

if (outcome.mode === 'read-only') {
const readOnlyId = readOnlyRuntimeIdFor(storedSessionId)

updateSessionState(
readOnlyId,
state => ({
...state,
busy: false,
awaitingResponse: false,
messages:
state.messages.length > 0 ? state.messages : toChatMessages(outcome.transcript?.messages ?? [])
}),
storedSessionId
)
])

notify({
kind: 'info',
title: translateNow('desktop.readOnlyTranscriptTitle'),
message: translateNow('desktop.readOnlyTranscriptBody')
})

return readOnlyId
}

const resumed = outcome.resumed

const runtimeId = resumed?.session_id

Expand Down Expand Up @@ -233,6 +290,15 @@ export function useSessionTileDelegate({
return runtimeId
},
submitToSession: async (runtimeId, text) => {
// A read-only stored-transcript tile has no live runtime to submit
// into (#94724). Refuse with the explanation instead of minting a
// misrouted prompt on a backend that never owned the session.
if (isReadOnlyRuntimeId(runtimeId)) {
notify({ kind: 'info', message: translateNow('desktop.readOnlyTranscriptSendBlocked') })

return
}

const storedSessionId = storedSessionIdForRuntime(runtimeId)

const routedRequest = storedSessionId
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { $hudMode } from '@/store/hud'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { consumePendingCredentialWarning, requestDesktopOnboarding } from '@/store/onboarding'
import { isStoredTranscriptReadOnly } from '@/store/read-only-transcript'
import {
$sessions,
resolveComposerSessionKey,
Expand Down Expand Up @@ -209,6 +210,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// to another chat.
let targetStoredSessionId = options?.storedSessionId ?? selectedStoredSessionIdRef.current

// A read-only stored-transcript open (#94724: owner unresolvable under
// registry topology) has no routable live runtime — refuse the send
// with the explanation rather than minting a prompt on a backend that
// never owned the session.
if (isStoredTranscriptReadOnly(targetStoredSessionId)) {
notify({ kind: 'info', message: copy.readOnlyTranscriptSendBlocked })

return false
}

let targetStartedInCurrentView =
!targetStoredSessionId || targetStoredSessionId === selectedStoredSessionIdRef.current

Expand Down
55 changes: 54 additions & 1 deletion apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { NO_PROJECT_ID } from '@/app/chat/sidebar/projects/workspace-groups'
import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill'
import { revealTreePane } from '@/components/pane-shell/tree/store'
import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import { deleteSession, getAllSessionMessages, getLatestSessionMessages, setSessionArchived } from '@/hermes'
import {
deleteSession,
fetchStoredTranscriptAcrossBackends,
getAllSessionMessages,
getLatestSessionMessages,
setSessionArchived
} from '@/hermes'
import { useI18n } from '@/i18n'
import {
type ChatMessage,
Expand Down Expand Up @@ -52,6 +58,7 @@ import {
untombstoneSessions
} from '@/store/projects'
import { setApprovalRequest } from '@/store/prompts'
import { clearStoredTranscriptReadOnly, markStoredTranscriptReadOnly } from '@/store/read-only-transcript'
import {
$activeSessionStoredIdRotation,
$connection,
Expand Down Expand Up @@ -90,6 +97,7 @@ import {
setWorkspaceCwdOwner,
setYoloActive
} from '@/store/session'
import { isSessionOwnerResolutionError } from '@/store/session-owner-resolution'
import {
requestForSessionProfile,
type SessionOwnerScope,
Expand Down Expand Up @@ -1583,6 +1591,10 @@ export function useSessionActions({

setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
// A live resume proves the owner routed — retire any read-only latch
// a previous no-owner open left behind (#94724: the backfill stamped
// the row, or a topology change made the owner resolvable again).
clearStoredTranscriptReadOnly(storedSessionId)
const pendingApproval = restorePendingApproval(resumed, resumed.session_id)
const pendingClarifyState = restorePendingClarifyFromSnapshot(resumed, resumed.session_id, resumeStartedAt)
const pendingClarify = pendingClarifyState.request
Expand Down Expand Up @@ -1723,6 +1735,47 @@ export function useSessionActions({
return
}

// #94724 no-owner recovery: the owner ladder failed closed — which is
// CORRECT under registry topology — but the stored transcript may be
// fully intact in some backend's state.db. If the ambient REST
// fallback above didn't already paint it, probe the registered
// backends READ-ONLY (id-only GET; no live session is routed or
// minted anywhere). When history is reachable, open the session
// read-only instead of dead-ending on the resolution error: writes
// stay blocked, and a later resume (after the single-match owner
// backfill stamps the row) upgrades it back to a live session.
if (isSessionOwnerResolutionError(err)) {
let painted = !fallbackError && viewMessagesForReconcile().length > 0

if (!painted) {
const stored = await fetchStoredTranscriptAcrossBackends(storedSessionId).catch(() => null)

if (!isCurrentResume()) {
return
}

if (stored && stored.messages.length > 0) {
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages(viewMessagesForReconcile(), resumeStartMessages)
: viewMessagesForReconcile()

setMessages(reconcileAuthoritativeMessages(stored.messages, previousMessages))
painted = true
}
}

if (painted) {
markStoredTranscriptReadOnly(storedSessionId)
notify({
kind: 'info',
title: copy.readOnlyTranscriptTitle,
message: copy.readOnlyTranscriptBody
})

return
}
}

// The session is genuinely gone (deleted, or a stale id from a wiped /
// rotated backend): the resume RPC and the authoritative REST transcript
// both 404. There's nothing to recover — silently drop to a fresh draft
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2779,6 +2779,9 @@ export const ar = defineLocale({
editFailed: 'فشل التحرير',
editTurnUnavailable: 'هذه الجولة لم تعد في سجل الخادم (ربما أزيلت بالضغط).',
resumeFailed: 'فشل الاستئناف',
readOnlyTranscriptTitle: 'فُتحت للقراءة فقط',
readOnlyTranscriptBody: 'لا يوجد بعد خادم متصل يملك هذه المحادثة القديمة، لذا فُتحت كنصّ محفوظ للقراءة فقط. السجل سليم؛ الإرسال معطّل حتى يتبنّاها خادم.',
readOnlyTranscriptSendBlocked: 'هذه المحادثة مفتوحة كنصّ محفوظ للقراءة فقط — الإرسال معطّل.',
resumeStrandedTitle: 'تعذّر تحميل هذه الجلسة',
resumeStrandedBody:
'فشل الاتصال بهذه الجلسة وتوقفت إعادة المحاولة التلقائية. تأكد من تشغيل البوابة، ثم حاول مجددا.',
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3408,6 +3408,10 @@ export const en: Translations = {
editFailed: 'Edit failed',
editTurnUnavailable: 'This turn is no longer in server history (it may have been compressed away).',
resumeFailed: 'Resume failed',
readOnlyTranscriptTitle: 'Opened read-only',
readOnlyTranscriptBody:
'No connected backend claims this older chat yet, so it opened as a read-only transcript. Its history is intact; sending is disabled until a backend claims it.',
readOnlyTranscriptSendBlocked: 'This chat is open as a read-only transcript — sending is disabled.',
resumeStrandedTitle: "Couldn't load this session",
resumeStrandedBody:
'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3030,6 +3030,9 @@ export const ja = defineLocale({
editFailed: '編集に失敗しました',
editTurnUnavailable: 'このターンはサーバー履歴にありません(圧縮で削除された可能性があります)。',
resumeFailed: '再開に失敗しました',
readOnlyTranscriptTitle: '読み取り専用で開きました',
readOnlyTranscriptBody: 'この古いチャットを所有するバックエンドがまだ接続されていないため、読み取り専用のトランスクリプトとして開きました。履歴は無事です。バックエンドが所有を認識するまで送信は無効です。',
readOnlyTranscriptSendBlocked: 'このチャットは読み取り専用トランスクリプトとして開いています。送信は無効です。',
resumeStrandedTitle: 'このセッションを読み込めませんでした',
resumeStrandedBody:
'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2928,6 +2928,9 @@ export interface Translations {
editFailed: string
editTurnUnavailable: string
resumeFailed: string
readOnlyTranscriptTitle: string
readOnlyTranscriptBody: string
readOnlyTranscriptSendBlocked: string
resumeStrandedTitle: string
resumeStrandedBody: string
resumeRetry: string
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2905,6 +2905,9 @@ export const zhHant = defineLocale({
editFailed: '編輯失敗',
editTurnUnavailable: '此回合已不在伺服器歷史中(可能已被壓縮移除)。',
resumeFailed: '繼續失敗',
readOnlyTranscriptTitle: '已以唯讀方式開啟',
readOnlyTranscriptBody: '尚無已連線的後端認領這個較早的對話,因此它以唯讀逐字稿方式開啟。歷史紀錄完好;在有後端認領之前無法傳送訊息。',
readOnlyTranscriptSendBlocked: '此對話目前以唯讀逐字稿方式開啟——傳送已停用。',
resumeStrandedTitle: '無法載入此工作階段',
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
resumeRetry: '重試',
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3553,6 +3553,9 @@ export const zh: Translations = {
editFailed: '编辑失败',
editTurnUnavailable: '此回合已不在服务器历史中(可能已被压缩移除)。',
resumeFailed: '恢复失败',
readOnlyTranscriptTitle: '已以只读方式打开',
readOnlyTranscriptBody: '尚无已连接的后端认领这个较早的会话,因此它以只读记录方式打开。历史记录完好;在有后端认领之前无法发送消息。',
readOnlyTranscriptSendBlocked: '该会话目前以只读记录方式打开——发送已禁用。',
resumeStrandedTitle: '无法加载此会话',
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
resumeRetry: '重试',
Expand Down
Loading
Loading