From cf5c925bbdec0daf5558204474dffd81037fb711 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Tue, 15 Sep 2026 00:09:56 +0800 Subject: [PATCH 1/6] feat: persist session submissions and reconcile delivery Model: gpt-6-astra --- .../2026-09-14-deferred-attachment-send.md | 6 + .../2026-09-14-deferred-attachment-send.zh.md | 6 + apps/electron/package.json | 2 +- apps/electron/src/AGENTS.md | 2 + apps/electron/src/main/index.ts | 42 +-- .../electron/src/main/ipc/services/app-ipc.ts | 26 +- apps/electron/src/main/menu.ts | 4 + apps/electron/src/main/renderer-recovery.ts | 10 + .../renderer-send-lifecycle-core.test.mjs | 66 +++++ .../services/renderer-send-lifecycle-core.ts | 21 ++ .../main/services/renderer-send-lifecycle.ts | 121 ++++++++ apps/electron/src/main/window.ts | 20 ++ locales/en.json | 19 +- locales/zh_CN.json | 19 +- packages/components/package.json | 1 + packages/components/src/atoms/runtime.ts | 4 + .../components/chat/session-send-recovery.tsx | 276 ++++++++++++++++++ .../components/src/components/login-page.tsx | 5 +- .../sessions/session-chat-interface.tsx | 8 + .../src/components/settings/clear-cache.tsx | 2 + .../src/hooks/use-session-actions.ts | 2 +- .../components/src/hooks/use-session-doc.ts | 27 +- packages/components/src/lib/auth.ts | 14 +- .../components/src/lib/clear-local-cache.ts | 42 ++- .../src/lib/session-send-admission.ts | 43 +++ .../components/src/lib/session-send-exit.ts | 14 + .../src/lib/session-send-journal-storage.ts | 236 +++++++++++++++ .../src/lib/session-send-journal.ts | 245 ++++++++++++++++ .../src/lib/session-send-resources.ts | 12 +- .../components/src/lib/session-submission.ts | 44 ++- packages/components/src/providers/AGENTS.md | 2 + .../src/providers/create-workspace-runtime.ts | 30 ++ .../src/providers/runtime-provider.tsx | 28 +- .../workspace-session-send-journal.ts | 267 +++++++++++++++++ .../src/providers/workspace-writer-impl.ts | 13 + .../src/providers/workspace-writer.ts | 1 + packages/components/src/routes/__root.tsx | 2 +- .../components/src/routes/join/$token.tsx | 3 +- .../tests/session-send-journal.test.ts | 259 ++++++++++++++++ .../tests/use-session-actions.test.ts | 38 ++- .../workspace-join-request-route.test.tsx | 2 +- .../components/tests/workspace-writer.test.ts | 20 ++ packages/shared/src/electron-ipc-channels.ts | 2 + packages/shared/src/history-writer.ts | 21 ++ packages/shared/src/session-data/AGENTS.md | 4 + packages/shared/src/session-data/loro.ts | 6 + packages/shared/src/session-data/types.ts | 3 + packages/shared/tests/history-writer.test.ts | 47 +++ pnpm-lock.yaml | 9 + specs/session-files.md | 2 + specs/session-files.zh.md | 2 + 51 files changed, 2024 insertions(+), 76 deletions(-) create mode 100644 apps/electron/src/main/services/renderer-send-lifecycle-core.test.mjs create mode 100644 apps/electron/src/main/services/renderer-send-lifecycle-core.ts create mode 100644 apps/electron/src/main/services/renderer-send-lifecycle.ts create mode 100644 packages/components/src/components/chat/session-send-recovery.tsx create mode 100644 packages/components/src/lib/session-send-admission.ts create mode 100644 packages/components/src/lib/session-send-exit.ts create mode 100644 packages/components/src/lib/session-send-journal-storage.ts create mode 100644 packages/components/src/lib/session-send-journal.ts create mode 100644 packages/components/src/providers/workspace-session-send-journal.ts create mode 100644 packages/components/tests/session-send-journal.test.ts diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md index 2af86260f..4457d0763 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md @@ -105,3 +105,9 @@ Layer 2 creates one workspace Effect resource owner for file preparation, image Deterministic tests cover parallel cancellation, late store acquisition, sibling isolation, actual XHR cancellation, and progress versus successful response. Transfer still starts on addition. Persistent submission and complete draft behavior remain the next two layers. Layer 2 validation: `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes completely (components: 478 files, 3,661 tests). `pnpm format` and `pnpm run docs check` completed; docs have no errors. Packaged-device draft acceptance remains outstanding. + +Layer 2 PR: [#707](https://github.com/LodyAI/Lody/pull/707), based on #705. + +Layer 3 is in progress. To close the appended-history/lost-local-receipt window, the same HistoryWriter abstraction prepares operations on a temporary fork, persists the original replica name and exact operation bytes, and only then imports them into the live document. Restart replays the same operations instead of appending again. Flush the original baseline before publishing prepared operations; another window first loads that baseline, retaining the record and stopping if unavailable. A fresh empty replica cannot prove non-submission. Real Loro tests cover replay across two replicas, missing dependencies, and validation refusal. The journal includes strict IndexedDB receipts, account/workspace isolation, cross-window locks and invalidations, a recovery panel, and renderer exit checks before CLI shutdown. Imported prepared operations are explicitly synchronized through the existing target transport; a transport receipt is not Agent execution. Logout and cache/reset preserve outstanding recovery records. Expired authentication still fences access immediately. Transfer timing remains unchanged until layer 4. Packaged desktop/mobile acceptance remains outstanding. + +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md index 2ce6448f5..24e6a3149 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md @@ -102,3 +102,9 @@ public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并 新增确定性测试覆盖并行取消、迟到的 store 获取、兄弟任务隔离、XHR 实际取消及上传进度与成功响应的区别。该层保持添加时上传;持久化发送和完整 draft 行为仍属于后两层。 第二层验证:`TMPDIR=/private/tmp NODE_ENV=test pnpm check` 全部通过(组件 478 个文件、3,661 个测试),`pnpm format` 和 `pnpm run docs check` 已完成;文档无错误。仍未声称完成真实设备上的 draft 验收。 + +第二层 PR:[#707](https://github.com/LodyAI/Lody/pull/707),基于 #705。 + +第三层正在实现。为关闭“已追加历史但磁盘确认丢失”的窗口,在同一个 HistoryWriter 抽象内先在临时 fork 准备操作,保存原副本名称及原始操作字节,然后才导入当前文档。重启重放相同操作,不重新 append。先 flush 原副本以保留操作依赖;跨窗口恢复先读取原副本,缺失时保留记录并停止,不以新窗口的空历史推断未发送。真实 Loro 测试已覆盖两副本重复重放、缺失依赖与校验失败;运行时、退出、UI 以及完整 IndexedDB 验证仍未接完,不能发布这一层。 + +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. diff --git a/apps/electron/package.json b/apps/electron/package.json index 9a78a4b0f..1c96e81ef 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -10,7 +10,7 @@ "homepage": "https://github.com/LodyAI/Lody", "scripts": { "format": "oxfmt", - "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/devbar.test.mjs src/system-language-argument.test.mjs src/main/reload-shortcut.test.mjs src/main/close-focused-tab-or-window.test.mjs src/main/onboarding-launch-policy.test.mjs src/main/auto-launch-policy.test.mjs src/main/context-menu-template.test.mjs src/main/window-runtime-policy.test.mjs src/main/window-theme.test.mjs src/main/local-platform-snapshot.test.mjs src/main/ipc/ipc-channel-list.test.mjs src/main/services/local-file-resource.test.mjs src/main/services/local-path-launcher-core.test.mjs src/main/services/image-export-core.test.mjs src/main/services/notification-delivery.test.mjs src/main/services/public-browser-state.test.mjs src/main/services/app-updater-linux-install.test.mjs src/main/services/app-updater-metadata.test.mjs src/main/services/app-updater-sparkle-policy.test.mjs src/main/services/app-updater-sparkle-events.test.mjs src/main/services/loro-data-plane-relay.test.mjs src/renderer/renderer-csp.test.mjs src/renderer/src/auth-callback-transaction.test.mjs src/renderer/src/auth-query-generation.test.mjs src/renderer/src/renderer-error-reporting.test.mjs scripts/sparkle-packaging.test.mjs", + "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/devbar.test.mjs src/system-language-argument.test.mjs src/main/reload-shortcut.test.mjs src/main/close-focused-tab-or-window.test.mjs src/main/onboarding-launch-policy.test.mjs src/main/auto-launch-policy.test.mjs src/main/context-menu-template.test.mjs src/main/window-runtime-policy.test.mjs src/main/window-theme.test.mjs src/main/local-platform-snapshot.test.mjs src/main/ipc/ipc-channel-list.test.mjs src/main/services/local-file-resource.test.mjs src/main/services/local-path-launcher-core.test.mjs src/main/services/image-export-core.test.mjs src/main/services/renderer-send-lifecycle-core.test.mjs src/main/services/notification-delivery.test.mjs src/main/services/public-browser-state.test.mjs src/main/services/app-updater-linux-install.test.mjs src/main/services/app-updater-metadata.test.mjs src/main/services/app-updater-sparkle-policy.test.mjs src/main/services/app-updater-sparkle-events.test.mjs src/main/services/loro-data-plane-relay.test.mjs src/renderer/renderer-csp.test.mjs src/renderer/src/auth-callback-transaction.test.mjs src/renderer/src/auth-query-generation.test.mjs src/renderer/src/renderer-error-reporting.test.mjs scripts/sparkle-packaging.test.mjs", "lint": "eslint --cache .", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", diff --git a/apps/electron/src/AGENTS.md b/apps/electron/src/AGENTS.md index 5b0b7fa64..52dca0a64 100644 --- a/apps/electron/src/AGENTS.md +++ b/apps/electron/src/AGENTS.md @@ -115,3 +115,5 @@ native-dependency, and OSS-composition rules stay in `apps/electron/AGENTS.md`. - Text above the editor budget uses fixed bounded Range requests. Binary uses raw streams with backpressure/cancellation; raster header dimensions bound decode cost. The scheme never bypasses CSP, executes file content, or authorizes a remote RPC. + +- Product-window close/reload and app quit must check pending sends before draining renderer owners; stop the CLI only after every renderer drain settles. Keep cache/reset protection for recoverable messages. diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index ea9ca5159..ca9af4fc7 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -1,3 +1,4 @@ +import { prepareRendererSendsForExit } from './services/renderer-send-lifecycle' import { registerLocalFileResourceScheme, installLocalFileResourceProtocol @@ -310,34 +311,35 @@ if (hasSingleInstanceLock) { }) let cliShutdownComplete = false + let preparingQuit = false app.on('before-quit', (event) => { - setAppQuitting(true) - setWindowsTrayAvailable(false) - windowsTrayService.stop() - windowBadgeService.reset() - terminalRelay.destroy() - loroDataPlaneRelay.destroy() - appUpdaterService.stop() - publicBrowserService.destroyAll() - if (cliShutdownComplete) { - // Cleanup already ran on the first pass; let this quit proceed. cliService.killAllProcesses() return } - - // Defer the quit until the embedded CLI has actually exited. Killing it - // fire-and-forget would let the app exit while the CLI is still shutting - // down, orphaning it holding the local ports + terminal socket and breaking - // the next launch. shutdownForQuit() SIGTERMs, waits briefly, then SIGKILLs. event.preventDefault() - void Promise.allSettled([ - cliService.shutdownForQuit(), - flushElectronMainErrorReporting() - ]).finally(() => { + if (preparingQuit) return + preparingQuit = true + void (async () => { + if (!(await prepareRendererSendsForExit('quit'))) return + setAppQuitting(true) + setWindowsTrayAvailable(false) + windowsTrayService.stop() + windowBadgeService.reset() + terminalRelay.destroy() + loroDataPlaneRelay.destroy() + appUpdaterService.stop() + publicBrowserService.destroyAll() + await Promise.allSettled([cliService.shutdownForQuit(), flushElectronMainErrorReporting()]) cliShutdownComplete = true app.quit() - }) + })() + .catch((error: unknown) => { + console.error('[Electron] Could not finish renderer shutdown', error) + }) + .finally(() => { + preparingQuit = false + }) }) process.on('exit', () => { diff --git a/apps/electron/src/main/ipc/services/app-ipc.ts b/apps/electron/src/main/ipc/services/app-ipc.ts index b3d0c3604..ee8c0bcf1 100644 --- a/apps/electron/src/main/ipc/services/app-ipc.ts +++ b/apps/electron/src/main/ipc/services/app-ipc.ts @@ -1,3 +1,8 @@ +import { + registerRendererSendLifecycle, + resolveRendererSendLifecycle, + prepareRendererSendsForExit +} from '../../services/renderer-send-lifecycle' import { assertProductWindowSender } from '../assert-sender' import { productWindows } from '../../window-state' import { parseWindowTarget, openSessionWindow, type WindowTarget } from '../../session-windows' @@ -97,6 +102,21 @@ export function installNativeThemeWatch(): void { export class AppIpc extends IpcService { static override readonly groupName = 'app' + @IpcMethod() + async registerSendLifecycle() { + const { event } = getIpcContext() + assertProductWindowSender(event) + const window = BrowserWindow.fromWebContents(event.sender) + if (window) registerRendererSendLifecycle(window) + } + + @IpcMethod() + async replySendLifecycle(input: unknown) { + const { event } = getIpcContext() + assertProductWindowSender(event) + resolveRendererSendLifecycle(event.sender.id, input) + } + @IpcMethod() async openWindow(raw: WindowTarget) { const { event } = getIpcContext() @@ -109,7 +129,11 @@ export class AppIpc extends IpcService { const { event } = getIpcContext() assertProductWindowSender(event) for (const window of productWindows) { - if (window.webContents !== event.sender) window.destroy() + if (window.webContents !== event.sender) { + if (!(await prepareRendererSendsForExit('close', window))) + throw new Error('Cache clearing was cancelled') + window.destroy() + } } } diff --git a/apps/electron/src/main/menu.ts b/apps/electron/src/main/menu.ts index dd7698976..fb21edf62 100644 --- a/apps/electron/src/main/menu.ts +++ b/apps/electron/src/main/menu.ts @@ -274,3 +274,7 @@ export function setMenuLanguage(locale: string): void { } buildAndSetMenu() } + +export function translateAppText(key: string): string { + return t(currentLocale, key) +} diff --git a/apps/electron/src/main/renderer-recovery.ts b/apps/electron/src/main/renderer-recovery.ts index 0427edba6..e4ee667c9 100644 --- a/apps/electron/src/main/renderer-recovery.ts +++ b/apps/electron/src/main/renderer-recovery.ts @@ -1,3 +1,4 @@ +import { prepareRendererSendsForExit } from './services/renderer-send-lifecycle' import { app, BrowserWindow, type WebContents } from 'electron' import { promises as fs } from 'node:fs' import path from 'node:path' @@ -111,6 +112,15 @@ function loadTarget(window: BrowserWindow, target: ReloadTarget): Promise } export function requestRendererReload(window: BrowserWindow): void { + if (window.isDestroyed()) return + void prepareRendererSendsForExit('reload', window) + .then((allowed) => { + if (allowed && !window.isDestroyed()) reloadRendererAfterCleanup(window) + }) + .catch((error: unknown) => console.error('[Electron] Reload cleanup failed', error)) +} + +function reloadRendererAfterCleanup(window: BrowserWindow): void { if (window.isDestroyed()) return const state = getState(window) state.hasNotifiedMounted = false diff --git a/apps/electron/src/main/services/renderer-send-lifecycle-core.test.mjs b/apps/electron/src/main/services/renderer-send-lifecycle-core.test.mjs new file mode 100644 index 000000000..9361d4eba --- /dev/null +++ b/apps/electron/src/main/services/renderer-send-lifecycle-core.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { runRendererSendExit } from './renderer-send-lifecycle-core.ts' + +function gate() { + let resolve + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +void test('Stay preserves every live renderer and its dependencies', async () => { + const windows = [{ live: true }, { live: true }] + const allowed = await runRendererSendExit(windows, { + check: async () => ({ ready: true, pending: true }), + unavailable: async () => {}, + confirm: async () => false, + drain: async (window) => { + window.live = false + return { ready: true, pending: false } + } + }) + assert.equal(allowed, false) + assert.deepEqual(windows, [{ live: true }, { live: true }]) +}) + +void test('one unresponsive renderer cannot authorize stopping the CLI', async () => { + let cliStopped = false + const allowed = await runRendererSendExit([1, 2], { + check: async (window) => ({ ready: window === 1, pending: false }), + unavailable: async () => {}, + confirm: async () => true, + drain: async () => ({ ready: true, pending: false }) + }) + if (allowed) cliStopped = true + assert.equal(cliStopped, false) +}) + +void test('all renderer cleanup must settle before stopping dependencies', async () => { + const started = [gate(), gate()] + const finish = [gate(), gate()] + const live = [true, true] + let cliStopped = false + const exit = runRendererSendExit([0, 1], { + check: async () => ({ ready: true, pending: true }), + unavailable: async () => {}, + confirm: async () => true, + drain: async (index) => { + started[index].resolve() + await finish[index].promise + live[index] = false + return { ready: true, pending: false } + } + }).then((allowed) => { + if (allowed) cliStopped = true + }) + await Promise.all(started.map((item) => item.promise)) + finish[0].resolve() + assert.equal(cliStopped, false) + assert.equal(live[1], true) + finish[1].resolve() + await exit + assert.deepEqual(live, [false, false]) + assert.equal(cliStopped, true) +}) diff --git a/apps/electron/src/main/services/renderer-send-lifecycle-core.ts b/apps/electron/src/main/services/renderer-send-lifecycle-core.ts new file mode 100644 index 000000000..f6e0e8882 --- /dev/null +++ b/apps/electron/src/main/services/renderer-send-lifecycle-core.ts @@ -0,0 +1,21 @@ +export type RendererSendExitReply = { ready: boolean; pending: boolean } + +/** Approval is a separate phase: denying exit must leave every renderer usable. */ +export async function runRendererSendExit( + targets: readonly T[], + ports: { + check(target: T): Promise + unavailable(): Promise + confirm(): Promise + drain(target: T): Promise + } +): Promise { + const checks = await Promise.all(targets.map((target) => ports.check(target))) + if (checks.some((reply) => !reply.ready)) { + await ports.unavailable() + return false + } + if (checks.some((reply) => reply.pending) && !(await ports.confirm())) return false + const stopped = await Promise.all(targets.map((target) => ports.drain(target))) + return stopped.every((reply) => reply.ready) +} diff --git a/apps/electron/src/main/services/renderer-send-lifecycle.ts b/apps/electron/src/main/services/renderer-send-lifecycle.ts new file mode 100644 index 000000000..97d73c9de --- /dev/null +++ b/apps/electron/src/main/services/renderer-send-lifecycle.ts @@ -0,0 +1,121 @@ +import { runRendererSendExit } from './renderer-send-lifecycle-core' +import { translateAppText as t } from '../menu' +import { randomUUID } from 'node:crypto' +import { dialog, type BrowserWindow } from 'electron' +import { IPC_PUSH_CHANNELS } from '@lody/shared/electron-ipc' +import { productWindows } from '../window-state' + +type Reply = { ready: boolean; pending: boolean } +const registered = new Set() +const requests = new Map void }>() + +export function registerRendererSendLifecycle(window: BrowserWindow): void { + const id = window.webContents.id + if (registered.has(id)) return + registered.add(id) + window.webContents.once('destroyed', () => { + registered.delete(id) + for (const [requestId, request] of requests) { + if (request.senderId === id) { + requests.delete(requestId) + request.resolve({ ready: false, pending: true }) + } + } + }) +} + +export function resolveRendererSendLifecycle(senderId: number, input: unknown): void { + if (!input || typeof input !== 'object') throw new Error('Invalid lifecycle response') + const value = input as { requestId?: unknown; ready?: unknown; pending?: unknown } + if ( + typeof value.requestId !== 'string' || + typeof value.ready !== 'boolean' || + typeof value.pending !== 'boolean' + ) + throw new Error('Invalid lifecycle response') + const request = requests.get(value.requestId) + if (!request || request.senderId !== senderId) + throw new Error('Lifecycle response owner mismatch') + requests.delete(value.requestId) + request.resolve({ ready: value.ready, pending: value.pending }) +} + +function requestLifecycle( + window: BrowserWindow, + phase: 'check' | 'commit', + reason: 'quit' | 'reload' | 'close' +): Promise { + if (!registered.has(window.webContents.id)) + return Promise.resolve({ ready: true, pending: false }) + const requestId = randomUUID() + return new Promise((resolve) => { + // A check may time out conservatively. Cleanup itself must join raw IPC, + // so the commit phase cannot pretend that elapsed time released ownership. + const timer = + phase === 'check' + ? setTimeout(() => { + requests.delete(requestId) + resolve({ ready: false, pending: true }) + }, 5000) + : undefined + requests.set(requestId, { + senderId: window.webContents.id, + resolve: (reply) => { + clearTimeout(timer) + resolve(reply) + } + }) + try { + window.webContents.send(IPC_PUSH_CHANNELS.appSendLifecycle, { requestId, phase, reason }) + } catch { + requests.delete(requestId) + clearTimeout(timer) + resolve({ ready: false, pending: true }) + } + }) +} + +/** Approve first, then drain every renderer before main destroys transports or CLI. */ +export async function prepareRendererSendsForExit( + reason: 'quit' | 'reload' | 'close', + target?: BrowserWindow +): Promise { + const windows = (target ? [target] : [...productWindows]).filter( + (window) => !window.isDestroyed() + ) + return runRendererSendExit(windows, { + check: (window) => requestLifecycle(window, 'check', reason), + unavailable: async () => { + await dialog.showMessageBox({ + type: 'warning', + title: 'Lody', + message: t('sessions.pendingSendExitUnavailable'), + buttons: [t('sessions.stayWithPendingSends')], + defaultId: 0, + cancelId: 0 + }) + }, + confirm: async () => { + const confirmation = await dialog.showMessageBox({ + type: 'warning', + title: 'Lody', + message: t('sessions.pendingSendExitTitle'), + detail: t('sessions.pendingSendRetainedExit'), + buttons: [ + t('sessions.stayWithPendingSends'), + t( + reason === 'quit' + ? 'sessions.pendingSendQuit' + : reason === 'close' + ? 'sessions.pendingSendClose' + : 'sessions.pendingSendReload' + ) + ], + defaultId: 0, + cancelId: 0 + }) + return confirmation.response === 1 + }, + drain: (window) => requestLifecycle(window, 'commit', reason) + }) +} diff --git a/apps/electron/src/main/window.ts b/apps/electron/src/main/window.ts index 5f0d3b809..b3a7f2840 100644 --- a/apps/electron/src/main/window.ts +++ b/apps/electron/src/main/window.ts @@ -1,3 +1,4 @@ +import { prepareRendererSendsForExit } from './services/renderer-send-lifecycle' import { app, BrowserWindow, dialog, nativeTheme, shell } from 'electron' import { is } from '@electron-toolkit/utils' import { installContextMenu } from './context-menu' @@ -383,6 +384,25 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo pendingInitialMaximize.add(window) } productWindows.add(window) + let closingAfterSendCleanup = false + window.on('close', (event) => { + if (isAppQuitting()) return + const hidesInsteadOfClosing = + !options.auxiliary && + (process.platform === 'darwin' || (process.platform === 'win32' && isWindowsTrayAvailable())) + if (hidesInsteadOfClosing) return + event.preventDefault() + if (closingAfterSendCleanup) return + closingAfterSendCleanup = true + void prepareRendererSendsForExit('close', window) + .then((allowed) => { + if (allowed && !window.isDestroyed()) window.destroy() + }) + .catch((error: unknown) => console.error('[Electron] Window close cleanup failed', error)) + .finally(() => { + closingAfterSendCleanup = false + }) + }) window.once('closed', () => { productWindows.delete(window) if (getMainWindow() === window) { diff --git a/locales/en.json b/locales/en.json index c72bf7d3b..98a219d89 100644 --- a/locales/en.json +++ b/locales/en.json @@ -4149,5 +4149,22 @@ "promptShortcut.loadFailed": "Could not load Shortcut. Select it again to retry.", "settings.beta.promptShortcutsHelper": "Create reusable prompts and insert them with /. In development — expect rough edges.", "settings.promptShortcuts.disabled": "Enable Prompt Shortcuts under Developer mode in Settings → About to use this feature.", - "sessions.attachmentTransferInterrupted": "Transfer interrupted. Retry when you return." + "sessions.attachmentTransferInterrupted": "Transfer interrupted. Retry when you return.", + "sessions.pendingSends": "Pending messages ({{count}})", + "sessions.sendRecoveryUnavailable": "Could not read pending messages. Your saved content has been retained.", + "sessions.attachmentMessage": "Message with attachments", + "sessions.sendWaitingForSync": "Saved to history · Waiting for sync", + "sessions.sendConfirmingResult": "Confirming send result", + "sessions.sendSavedLocally": "Saved locally · Not submitted", + "sessions.retryPendingSend": "Continue sending", + "sessions.cancelPendingSend": "Cancel send", + "sessions.pendingSendExitTitle": "Some messages are still pending", + "sessions.pendingSendDestructiveExit": "Finish or cancel pending messages before signing out or clearing caches. Some messages may already have been sent; keep their recovery data until the result is confirmed.", + "sessions.pendingSendRetainedExit": "Pending messages will stay saved on this device. Some may already have been sent. Return to this workspace to confirm the result or continue sending.", + "sessions.stayWithPendingSends": "Stay", + "sessions.leaveWithPendingSends": "Leave and keep messages", + "sessions.pendingSendExitUnavailable": "Could not confirm pending messages. Keep Lody open and try again.", + "sessions.pendingSendQuit": "Quit and keep messages", + "sessions.pendingSendReload": "Reload and keep messages", + "sessions.pendingSendClose": "Close and keep messages" } diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 670a1899b..06321fc32 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -4149,5 +4149,22 @@ "promptShortcut.loadFailed": "无法加载快捷方式,请重新选择以重试。", "settings.beta.promptShortcutsHelper": "创建可复用的提示词,通过 / 插入。功能开发中,体验可能不完善。", "settings.promptShortcuts.disabled": "请在设置 → 关于的开发者模式下开启 Prompt Shortcuts。", - "sessions.attachmentTransferInterrupted": "传输已中断,返回后可重试。" + "sessions.attachmentTransferInterrupted": "传输已中断,返回后可重试。", + "sessions.pendingSends": "待发送消息({{count}})", + "sessions.sendRecoveryUnavailable": "无法读取待发送消息。已保存的内容仍被保留。", + "sessions.attachmentMessage": "带附件的消息", + "sessions.sendWaitingForSync": "已写入历史 · 等待同步", + "sessions.sendConfirmingResult": "正在确认发送结果", + "sessions.sendSavedLocally": "已保存在本机 · 尚未提交", + "sessions.retryPendingSend": "继续发送", + "sessions.cancelPendingSend": "取消发送", + "sessions.pendingSendExitTitle": "还有消息尚未完成发送", + "sessions.pendingSendDestructiveExit": "请先完成或取消待发送消息,再退出登录或清理缓存。部分消息可能已经发送,需要保留恢复数据来确认结果。", + "sessions.pendingSendRetainedExit": "待发送消息会保留在本机。部分消息可能已经发送。返回此工作区后可以确认结果或继续发送。", + "sessions.stayWithPendingSends": "留在此处", + "sessions.leaveWithPendingSends": "离开并保留消息", + "sessions.pendingSendExitUnavailable": "无法确认待发送消息的状态。请保持 Lody 打开并重试。", + "sessions.pendingSendQuit": "退出并保留消息", + "sessions.pendingSendReload": "重新加载并保留消息", + "sessions.pendingSendClose": "关闭并保留消息" } diff --git a/packages/components/package.json b/packages/components/package.json index 345993129..9ef58cfd9 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -220,6 +220,7 @@ "@types/three": "^0.185.1", "@types/validator": "^13.15.10", "@vitejs/plugin-react": "catalog:", + "fake-indexeddb": "6.2.5", "jsdom": "^26.1.0", "oxfmt": "^0.65.0", "postcss": "^8.5.4", diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index 20abc6a2c..e945b19be 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -1,3 +1,4 @@ +import type { createSessionSendJournal } from '../lib/session-send-journal'; import type { SessionSendResources } from '@/lib/session-send-resources'; import type { LocalFilePreviewResource } from '@lody/shared/local-file-preview'; import type { SessionData } from '@lody/shared/session-data'; @@ -173,6 +174,9 @@ export type WorkspaceRuntime = { */ readonly workspaceId: WorkspaceId; readonly sendResources: SessionSendResources; + readonly sendJournal: ReturnType | null; + readonly sourceReplica: string; + readonly accountId: string | null; readonly repo: LoroRepo; /** Workspace-owned, scoped LRU for owner-session file-index Flock resources. */ readonly codeCollabFileIndexCache: CodeCollabFileIndexCache; diff --git a/packages/components/src/components/chat/session-send-recovery.tsx b/packages/components/src/components/chat/session-send-recovery.tsx new file mode 100644 index 000000000..70fd7c2a2 --- /dev/null +++ b/packages/components/src/components/chat/session-send-recovery.tsx @@ -0,0 +1,276 @@ +import { getIpcServices, onIpcEvent } from '@/lib/electron-ipc-client'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useBlocker } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; +import type { WorkspaceRuntime } from '@/atoms/runtime'; +import type { SessionSendRecord } from '@/lib/session-send-journal'; +import { + registerSessionSendExitGuard, + requestSessionSendExit, + type SessionSendExitReason, +} from '@/lib/session-send-exit'; +import { hasPendingSessionSends } from '@/lib/session-send-journal-storage'; +import { Button } from '@/ui/button'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/ui/alert-dialog'; + +const EMPTY: readonly SessionSendRecord[] = []; +const emptySnapshot = () => EMPTY; +const emptySubscribe = () => () => {}; + +type ExitRequest = { reason: SessionSendExitReason; resolve: (allow: boolean) => void }; + +/** Small independent projection; transfer progress never subscribes the conversation tree. */ +export function SessionSendRecovery({ runtime }: { runtime: WorkspaceRuntime | null }) { + const { t } = useTranslation(); + const journal = runtime?.sendJournal; + const records = useSyncExternalStore( + journal?.subscribe ?? emptySubscribe, + journal?.getSnapshot ?? emptySnapshot, + emptySnapshot + ); + const pending = records.filter((record) => record.stage !== 'delivered'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [exitRequest, setExitRequest] = useState(null); + const exitRef = useRef(null); + const cancelRef = useRef(null); + const finishExit = useCallback((allow: boolean) => { + exitRef.current?.resolve(allow); + exitRef.current = null; + setExitRequest(null); + }, []); + + useEffect(() => { + setError(null); + if (!journal) return undefined; + let active = true; + void journal.refresh().catch((failure: unknown) => { + if (active) + setError( + failure instanceof Error ? failure.message : t('sessions.sendRecoveryUnavailable') + ); + }); + return () => { + active = false; + }; + }, [journal, t]); + + useEffect( + () => + registerSessionSendExitGuard(async (reason) => { + const active = + journal?.getSnapshot().filter((record) => record.stage !== 'delivered') ?? []; + let protectedRecords = + active.length > 0 || (runtime?.sendResources.getActiveCount() ?? 0) > 0; + if ((reason === 'logout' || reason === 'cache-clear') && typeof indexedDB !== 'undefined') { + // Unknown versions or unreadable recovery data cannot authorize destructive exit. + try { + protectedRecords ||= await hasPendingSessionSends( + indexedDB, + reason === 'logout' ? (runtime?.accountId ?? undefined) : undefined + ); + } catch { + protectedRecords = true; + } + } + if (!protectedRecords && !error) return true; + if (exitRef.current) return false; + return new Promise((resolve) => { + const request = { reason, resolve }; + exitRef.current = request; + setExitRequest(request); + }); + }), + [error, journal, runtime] + ); + + useEffect( + () => () => { + exitRef.current?.resolve(false); + }, + [] + ); + + useBlocker({ + shouldBlockFn: async ({ current, next }) => { + const currentWorkspace = (current.params as { workspaceName?: string }).workspaceName; + const nextWorkspace = (next.params as { workspaceName?: string }).workspaceName; + if (currentWorkspace === nextWorkspace) return false; + return !(await requestSessionSendExit('workspace')); + }, + enableBeforeUnload: () => + !!error || + (runtime?.sendResources.getActiveCount() ?? 0) > 0 || + !!journal?.getSnapshot().some((record) => record.stage !== 'delivered'), + }); + + useEffect(() => { + const ipc = getIpcServices(); + if (!ipc) return undefined; + const unsubscribe = onIpcEvent('app.sendLifecycle', (request) => { + void (async () => { + try { + if (request.phase === 'commit') await runtime?.dispose(); + await ipc.app.replySendLifecycle({ + requestId: request.requestId, + ready: true, + pending: + (runtime?.sendResources.getActiveCount() ?? 0) > 0 || + !!journal?.getSnapshot().some((record) => record.stage !== 'delivered'), + }); + } catch (failure) { + setError( + failure instanceof Error ? failure.message : t('sessions.sendRecoveryUnavailable') + ); + await ipc.app.replySendLifecycle({ + requestId: request.requestId, + ready: false, + pending: true, + }); + } + })().catch((failure: unknown) => + console.error('Could not report pending message lifecycle', failure) + ); + }); + void ipc.app + .registerSendLifecycle() + .catch((failure: unknown) => + console.error('Could not register pending message lifecycle', failure) + ); + return unsubscribe; + }, [journal, runtime, t]); + + const retry = async (record: SessionSendRecord) => { + if (!journal) return; + setBusy(record.id); + try { + await journal.retry(record.sessionId); + setError(null); + } catch (failure) { + setError(failure instanceof Error ? failure.message : t('sessions.sendRecoveryUnavailable')); + } finally { + setBusy(null); + } + }; + const cancel = async (record: SessionSendRecord) => { + if (!journal) return; + setBusy(record.id); + try { + await journal.cancel(record.id); + setError(null); + } catch (failure) { + setError(failure instanceof Error ? failure.message : t('sessions.sendRecoveryUnavailable')); + } finally { + setBusy(null); + } + }; + const destructiveExit = exitRequest?.reason === 'logout' || exitRequest?.reason === 'cache-clear'; + + return ( + <> + {pending.length > 0 || error ? ( +
+ + {t('sessions.pendingSends', { count: pending.length })} + + {error ? ( +

+ {error} +

+ ) : null} +
    + {pending.map((record) => ( +
  1. +

    + {record.entry.items + ?.flatMap((item) => + item.type === 'text' && 'text' in item && typeof item.text === 'string' + ? [item.text] + : [] + ) + .join('\n') || t('sessions.attachmentMessage')} +

    +

    + {t( + record.stage === 'committed' + ? 'sessions.sendWaitingForSync' + : record.stage === 'prepared' + ? 'sessions.sendConfirmingResult' + : 'sessions.sendSavedLocally' + )} +

    + {record.error ?

    {record.error}

    : null} +
    + + {record.stage === 'saved' ? ( + + ) : null} +
    +
  2. + ))} +
+
+ ) : null} + { + if (!open) finishExit(false); + }} + > + { + event.preventDefault(); + cancelRef.current?.focus(); + }} + > + + {t('sessions.pendingSendExitTitle')} + + {t( + destructiveExit + ? 'sessions.pendingSendDestructiveExit' + : 'sessions.pendingSendRetainedExit' + )} + + + + finishExit(false)}> + {t('sessions.stayWithPendingSends')} + + {!destructiveExit ? ( + + ) : null} + + + + + ); +} diff --git a/packages/components/src/components/login-page.tsx b/packages/components/src/components/login-page.tsx index 631d1fd37..2784231e0 100644 --- a/packages/components/src/components/login-page.tsx +++ b/packages/components/src/components/login-page.tsx @@ -1335,7 +1335,10 @@ export function LoginPage({ try { const outcome = await signOutWithoutRedirect(authClient); if (!outcome.ok) { - reportSwitchFailure('sign_out_rejected', outcome.error.message); + reportSwitchFailure( + outcome.cancelled ? 'sign_out_cancelled' : 'sign_out_rejected', + outcome.error?.message ?? 'Sign out cancelled' + ); } } catch (err) { // `signOutWithoutRedirect` reports failures rather than throwing; this diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index a69504689..8ddc3c985 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -3648,6 +3648,7 @@ export const SessionChatInterface = memo( } const { entry: historyEntry } = await addSessionHistory(pendingHistoryEntry, { dispatch: options?.requestDispatch === true, + guideExpectedTurnId: options?.guideExpectedTurnId, }); userTurnId = historyEntry.id; touchSessionActivity(session.id).catch((err: unknown) => { @@ -4055,6 +4056,11 @@ export const SessionChatInterface = memo( // NEW message — the old turn is never revived. const handleResendUndelivered = useCallback( async (userTurnId: string, inputBlocks: SessionInputBlock[]): Promise => { + const pending = await runtime?.sendJournal?.read(userTurnId); + if (pending && pending.stage !== 'delivered') { + await runtime!.sendJournal!.retry(session.id); + return true; + } // This is a new Turn with the old content, not a replay of the old run: // freeze the currently committed composer Role beside the current run // config. Copying only the original Role would pair it with unrelated @@ -4087,6 +4093,8 @@ export const SessionChatInterface = memo( return accepted; }, [ + runtime, + session.id, handleSendMessage, sessionConversationConfig.agentRoleId, sessionConversationConfig.agentRoleRevision, diff --git a/packages/components/src/components/settings/clear-cache.tsx b/packages/components/src/components/settings/clear-cache.tsx index 459406324..4d258f2a4 100644 --- a/packages/components/src/components/settings/clear-cache.tsx +++ b/packages/components/src/components/settings/clear-cache.tsx @@ -1,3 +1,4 @@ +import { requestSessionSendExit } from '@/lib/session-send-exit'; import { useCallback, useState } from 'react'; import { useNavigate, useParams } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; @@ -28,6 +29,7 @@ export function useClearCache() { const [isClearing, setIsClearing] = useState(false); const confirmClear = useCallback(async () => { + if (!(await requestSessionSendExit('cache-clear'))) { setDialogOpen(false); return; } setIsClearing(true); try { if (params.workspaceName) { diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index 8736f54a2..75fe53633 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -224,7 +224,7 @@ export type SessionActions = { addSessionHistory: ( sessionId: SessionId, history: Omit, - options?: { dispatch?: boolean } + options?: { dispatch?: boolean; guideExpectedTurnId?: string } ) => Promise; requestSessionDispatch: ( sessionId: SessionId, diff --git a/packages/components/src/hooks/use-session-doc.ts b/packages/components/src/hooks/use-session-doc.ts index 313341a56..a46190cba 100644 --- a/packages/components/src/hooks/use-session-doc.ts +++ b/packages/components/src/hooks/use-session-doc.ts @@ -1,7 +1,10 @@ +import { acceptSessionUserTurn } from '../lib/session-send-admission'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { arrayMove } from '@dnd-kit/sortable'; import { getServerNow, + buildPendingUserHistoryEntry, + normalizeSessionInputBlocks, normalizeSessionTurnInputConfig, type MessageQueueItem, type MessageQueueItemInput, @@ -57,7 +60,7 @@ export type UseSessionDocResult = { history: ConversationView | null; addHistory: ( history: Omit & { id?: string }, - options?: { dispatch?: boolean } + options?: { dispatch?: boolean; guideExpectedTurnId?: string } ) => Promise<{ entry: SessionHistory }>; pushMessageQueue: (item: PushMessageQueueInput) => Promise; removeMessageQueueItem: (cid: string) => Promise; @@ -263,12 +266,17 @@ export function useSessionDoc( const addHistory = useCallback( async ( item: Omit & { id?: string }, - writeOptions?: { dispatch?: boolean } + writeOptions?: { dispatch?: boolean; guideExpectedTurnId?: string } ) => { if (!runtime) { throw new Error('Runtime not ready'); } const entry = { ...item, id: item.id ?? uuidv4() } as SessionHistory; + if (entry.role === 'user') { + await acceptSessionUserTurn(runtime, sessionId, entry, + writeOptions?.guideExpectedTurnId ? { kind: 'guide', expectedTurnId: writeOptions.guideExpectedTurnId } : { kind: writeOptions?.dispatch ? 'dispatch' : 'queue' }); + return { entry }; + } if (writeOptions?.dispatch) { const inputConfig = normalizeSessionTurnInputConfig(entry.inputConfig); const userId = entry.userId?.trim(); @@ -306,10 +314,17 @@ export function useSessionDoc( timestamp: item.timestamp ?? new Date(getServerNow()).toISOString(), }; - await runtime.writer.enqueueSessionMessage( - sessionId, - entry as unknown as Record - ); + const inputConfig = normalizeSessionTurnInputConfig(entry.acpSessionConfig); + const userTurnId = entry.userTurnId ?? uuidv4(); + const pendingTurn = buildPendingUserHistoryEntry({ + userId: entry.userId, + inputBlocks: normalizeSessionInputBlocks(inputConfig?.inputBlocks, entry.task), + timestamp: entry.timestamp, + inputConfig, + }); + if (!pendingTurn) throw new Error('Queued message has no effective input'); + await acceptSessionUserTurn(runtime, sessionId, { ...pendingTurn, id: userTurnId } as SessionHistory, + { kind: 'queue' }, undefined, { ...entry, userTurnId }); }, [runtime, sessionId] ); diff --git a/packages/components/src/lib/auth.ts b/packages/components/src/lib/auth.ts index 443d9d10c..c67743eb9 100644 --- a/packages/components/src/lib/auth.ts +++ b/packages/components/src/lib/auth.ts @@ -1,3 +1,4 @@ +import { requestSessionSendExit } from './session-send-exit'; import { createAuthClient } from 'better-auth/react'; import { organizationClient } from 'better-auth/client/plugins'; import { convexClient, crossDomainClient } from '@convex-dev/better-auth/client/plugins'; @@ -73,11 +74,18 @@ export const persistAuthToken = (token: string) => { * transport failures by throwing and API failures in `response.error`; both * arrive here as `ok: false`. */ -export type SignOutOutcome = { ok: true } | { ok: false; error: AuthResponseError }; +export type SignOutOutcome = + | { ok: true } + | { ok: false; error: AuthResponseError; cancelled?: false } + | { ok: false; error: null; cancelled: true }; export const signOutWithoutRedirect = async ( - authClient: LodyAuthClient + authClient: LodyAuthClient, + options?: { sessionExpired?: boolean } ): Promise => { + if (!options?.sessionExpired && !(await requestSessionSendExit('logout'))) { + return { ok: false, error: null, cancelled: true }; + } // Fence token requests at logout intent, before Better Auth's async sign-out // updates useSession(). Otherwise a token request that completes in that // network window can still authenticate Convex as the previous user. @@ -102,6 +110,6 @@ export const signOutWithoutRedirect = async ( }; export const signOutWithAuthClient = async (authClient: LodyAuthClient) => { - await signOutWithoutRedirect(authClient); + if (!(await signOutWithoutRedirect(authClient)).ok) return; replaceAppWindowLocation(`${import.meta.env.BASE_URL}login`); }; diff --git a/packages/components/src/lib/clear-local-cache.ts b/packages/components/src/lib/clear-local-cache.ts index 431cab017..cacc348a8 100644 --- a/packages/components/src/lib/clear-local-cache.ts +++ b/packages/components/src/lib/clear-local-cache.ts @@ -1,3 +1,5 @@ +import { requestSessionSendExit } from './session-send-exit'; +import { hasPendingSessionSends, SESSION_SEND_DATABASE, SESSION_SEND_STORAGE_LOCK } from './session-send-journal-storage'; /** * "Clear cache" support shared by web, mobile (Capacitor), and desktop (Electron). * @@ -49,6 +51,7 @@ export type PendingLocalClearMode = 'cache' | 'hard'; /** IndexedDB databases created with static names (not suffixed per workspace). */ const KNOWN_INDEXEDDB_NAMES = [ + SESSION_SEND_DATABASE, EAGER_SYNC_HIGH_WATER_DB_NAME, EAGER_SYNC_CACHE_DB, 'lody:repo-file-paths', @@ -182,7 +185,15 @@ function deleteDatabaseBestEffort(name: string): Promise { * visited workspaces are already covered via the cached workspace-info map. */ export async function clearAllLodyLocalCache(extraNames: string[] = []): Promise { + if (typeof navigator !== 'undefined' && navigator.locks) { + return navigator.locks.request(SESSION_SEND_STORAGE_LOCK, () => clearRecoverableCache(extraNames)); + } + return clearRecoverableCache(extraNames); +} + +async function clearRecoverableCache(extraNames: string[]): Promise { if (typeof indexedDB !== 'undefined') { + if (await hasPendingSessionSends(indexedDB)) throw new Error('Pending messages need their local recovery data. Finish or cancel them before clearing caches.'); const names = new Set([ ...KNOWN_INDEXEDDB_NAMES, ...knownWorkspaceDatabaseNames(), @@ -191,7 +202,7 @@ export async function clearAllLodyLocalCache(extraNames: string[] = []): Promise try { const databases = (await indexedDB.databases?.()) ?? []; for (const database of databases) { - if (database.name && database.name.startsWith('lody')) { + if (database.name && database.name.startsWith('lody') && database.name !== SESSION_SEND_DATABASE) { names.add(database.name); } } @@ -203,7 +214,7 @@ export async function clearAllLodyLocalCache(extraNames: string[] = []): Promise // explicitly destructive hard reset below may remove these databases. await Promise.all( [...names] - .filter((name) => !name.startsWith(PROMPT_SHORTCUT_DATA_PREFIX)) + .filter((name) => name !== SESSION_SEND_DATABASE && !name.startsWith(PROMPT_SHORTCUT_DATA_PREFIX)) .map(deleteDatabaseBestEffort) ); } @@ -230,6 +241,17 @@ export async function clearAllLodyLocalCache(extraNames: string[] = []): Promise * signed out and factory-fresh. */ export async function clearAllLodyLocalData(extraNames: string[] = []): Promise { + const clear = async () => { + if (typeof indexedDB !== 'undefined' && await hasPendingSessionSends(indexedDB)) { + throw new Error('Pending messages must be completed or canceled before resetting local data'); + } + await clearRecoverableLocalData(extraNames); + }; + if (typeof navigator !== 'undefined' && navigator.locks) await navigator.locks.request(SESSION_SEND_STORAGE_LOCK, clear); + else await clear(); +} + +async function clearRecoverableLocalData(extraNames: string[]): Promise { clearWebStorage(); clearCookies(); @@ -378,6 +400,10 @@ async function revokeServerSessionBestEffort(): Promise { * crashed. */ export async function startHardReset(): Promise { + if (!(await requestSessionSendExit('cache-clear'))) return; + if (typeof indexedDB !== 'undefined' && await hasPendingSessionSends(indexedDB)) { + throw new Error('Pending messages must be completed or canceled before resetting local data'); + } await revokeServerSessionBestEffort(); clearWebStorage(); clearCookies(); @@ -435,9 +461,11 @@ let bootClearPromise: Promise | null = null; async function runPendingClearOnBoot(): Promise { const mode = readPendingLocalClearMode() ?? (await readNativePendingClearMode()); if (!mode) return null; - await getIpcServices()?.app.prepareCacheClear(); - try { + if (typeof indexedDB !== 'undefined' && await hasPendingSessionSends(indexedDB)) { + throw new Error('Pending messages must be completed or canceled before clearing caches'); + } + await getIpcServices()?.app.prepareCacheClear(); if (mode === 'hard') { await clearAllLodyLocalData(); } else { @@ -468,6 +496,10 @@ export async function maybeClearLodyCacheOnBoot(extraNames: string[] = []): Prom const mode = await bootClearPromise; // Nothing was pending, or this caller has no extra databases to contribute. if (!mode || extraNames.length === 0) return; + if (mode === 'cache') { + await clearAllLodyLocalCache(extraNames); + return; + } await Promise.all( extraNames .filter((name) => mode === 'hard' || !name.startsWith(PROMPT_SHORTCUT_DATA_PREFIX)) @@ -494,5 +526,5 @@ export function reloadApp(): void { return; } } - window.location.reload(); + void requestSessionSendExit('reload').then((allowed) => { if (allowed) window.location.reload(); }); } diff --git a/packages/components/src/lib/session-send-admission.ts b/packages/components/src/lib/session-send-admission.ts new file mode 100644 index 000000000..7ba801c12 --- /dev/null +++ b/packages/components/src/lib/session-send-admission.ts @@ -0,0 +1,43 @@ +import type { SessionHistory, SessionId, SessionMeta } from '@lody/shared'; +import type { WorkspaceRuntime } from '../atoms/runtime'; +import type { SessionSendRecord } from './session-send-journal'; + +/** Every user-message entrypoint shares the same durable admission and FIFO. */ +export async function acceptSessionUserTurn( + runtime: WorkspaceRuntime, + sessionId: SessionId, + entry: SessionHistory, + delivery: SessionSendRecord['delivery'], + creation?: SessionMeta, + queue?: Record +): Promise { + const journal = runtime.sendJournal; + if (!journal || !runtime.accountId) + throw new Error('Message recovery is not ready for this account'); + if (entry.role !== 'user' || entry.userId !== runtime.accountId) + throw new Error('Message account does not match the active workspace'); + await journal.accept({ + id: entry.id, + sessionId, + accountId: runtime.accountId, + workspaceId: runtime.workspaceId, + sourceReplica: runtime.sourceReplica, + entry, + creation, + delivery, + queue, + }); + // The saved input belongs to the workspace now. A later failure remains + // visible in its recovery list; it cannot invite a fresh composer resend. + try { + await journal.submit(sessionId); + const committed = journal.getSnapshot().find((record) => record.id === entry.id); + if (committed?.stage === 'committed') { + void journal.deliver(committed).catch((error: unknown) => { + console.warn('Message delivery remains pending', { sessionId, turnId: entry.id, error }); + }); + } + } catch (error) { + console.warn('Saved message remains pending', { sessionId, turnId: entry.id, error }); + } +} diff --git a/packages/components/src/lib/session-send-exit.ts b/packages/components/src/lib/session-send-exit.ts new file mode 100644 index 000000000..827c3a0c7 --- /dev/null +++ b/packages/components/src/lib/session-send-exit.ts @@ -0,0 +1,14 @@ +export type SessionSendExitReason = 'workspace' | 'logout' | 'reload' | 'cache-clear' | 'quit'; +type ExitGuard = (reason: SessionSendExitReason) => Promise; +let guard: ExitGuard | undefined; + +export function registerSessionSendExitGuard(next: ExitGuard): () => void { + guard = next; + return () => { + if (guard === next) guard = undefined; + }; +} + +export function requestSessionSendExit(reason: SessionSendExitReason): Promise { + return guard ? guard(reason) : Promise.resolve(true); +} diff --git a/packages/components/src/lib/session-send-journal-storage.ts b/packages/components/src/lib/session-send-journal-storage.ts new file mode 100644 index 000000000..e0b775aa9 --- /dev/null +++ b/packages/components/src/lib/session-send-journal-storage.ts @@ -0,0 +1,236 @@ +import type { SessionSendJournalStorage, SessionSendRecord } from './session-send-journal'; + +const STORE = 'submissions'; +export const SESSION_SEND_DATABASE = 'lody-session-send-v1'; +export const SESSION_SEND_STORAGE_LOCK = 'lody-session-send-storage'; +const MAX_PENDING_RECORDS = 100; +const MAX_PENDING_BYTES = 128 * 1024 * 1024; + +function requestValue(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('Recovery storage request failed')); + }); +} +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => + reject(transaction.error ?? new Error('Recovery storage transaction aborted')); + transaction.onerror = () => + reject(transaction.error ?? new Error('Recovery storage transaction failed')); + }); +} + +function decodeRecords( + values: unknown[], + accountId: string, + workspaceId: string +): SessionSendRecord[] { + return values.map((value) => { + if (!value || typeof value !== 'object') throw new Error('Invalid session recovery record'); + const record = value as SessionSendRecord; + if (record.version !== 1) + throw new Error('Unsupported session recovery version; execute a compatible application'); + if ( + record.accountId !== accountId || + record.workspaceId !== workspaceId || + typeof record.id !== 'string' || + !record.id || + record.entry?.id !== record.id || + typeof record.sessionId !== 'string' || + typeof record.sourceReplica !== 'string' || + !Number.isSafeInteger(record.sequence) || + !['saved', 'prepared', 'committed', 'delivered'].includes(record.stage) || + !['queue', 'dispatch', 'guide'].includes(record.delivery?.kind) || + (record.stage !== 'saved' && !(record.update instanceof Uint8Array)) + ) { + throw new Error('Invalid session recovery record; content retained for recovery'); + } + return record; + }); +} + +/** Account/workspace scoped, strict transaction receipts; failed admissions preserve the composer. */ +export function createSessionSendJournalStorage(args: { + accountId: string; + workspaceId: string; + indexedDB?: IDBFactory; +}): SessionSendJournalStorage { + const factory = args.indexedDB ?? globalThis.indexedDB; + const name = SESSION_SEND_DATABASE; + const scope = [args.accountId, args.workspaceId]; + const key = (id: string) => [...scope, id]; + let closed = false; + let opened: Promise | undefined; + const database = () => { + if (closed) return Promise.reject(new Error('Recovery storage closed')); + return (opened ??= new Promise((resolve, reject) => { + const request = factory.open(name, 1); + request.onupgradeneeded = () => { + const store = request.result.createObjectStore(STORE, { + keyPath: ['accountId', 'workspaceId', 'id'], + }); + store.createIndex('scope', ['accountId', 'workspaceId']); + store.createIndex('stage', 'stage'); + }; + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + closed = true; + db.close(); + }; + resolve(db); + }; + request.onerror = () => reject(request.error ?? new Error('Cannot open recovery storage')); + request.onblocked = () => + reject(new Error('Recovery storage upgrade blocked by another window')); + })); + }; + const mutate = async (execute: (store: IDBObjectStore) => Promise): Promise => { + const db = await database(); + const transaction = db.transaction(STORE, 'readwrite', { durability: 'strict' }); + const done = transactionDone(transaction); + void done.catch(() => {}); + // Attach rejection handling immediately; a request can fail before execute settles. + void done.catch(() => {}); + try { + const result = await execute(transaction.objectStore(STORE)); + await done; + return result; + } catch (error) { + try { + transaction.abort(); + } catch { + /* already settled */ + } + await done.catch(() => {}); + throw error; + } + }; + return { + list: async () => { + const db = await database(); + const transaction = db.transaction(STORE, 'readonly'); + const done = transactionDone(transaction); + void done.catch(() => {}); + const values = await requestValue( + transaction.objectStore(STORE).index('scope').getAll(scope) + ); + await done; + return decodeRecords(values, args.accountId, args.workspaceId); + }, + insert: (record) => + mutate(async (store) => { + decodeRecords([{ ...record, sequence: 1 }], args.accountId, args.workspaceId); + const values = decodeRecords( + await requestValue(store.index('scope').getAll(scope)), + args.accountId, + args.workspaceId + ); + const existing = values.find((value) => value.id === record.id); + if (existing) { + if ( + JSON.stringify(existing.entry) !== JSON.stringify(record.entry) || + existing.sessionId !== record.sessionId + ) + throw new Error('Submission identity conflicts with saved content'); + return existing; + } + const active = values.filter((value) => value.stage !== 'delivered'); + const bytes = active.reduce( + (sum, value) => + sum + JSON.stringify(value.entry).length * 2 + (value.update?.byteLength ?? 0), + 0 + ); + if ( + active.length >= MAX_PENDING_RECORDS || + bytes + JSON.stringify(record.entry).length * 2 > MAX_PENDING_BYTES + ) { + throw new Error( + 'Pending message storage is full; finish or remove pending messages first' + ); + } + // Delivered rows contain redundant input. Retire them only on the next successful admission. + for (const value of values) + if (value.stage === 'delivered') await requestValue(store.delete(key(value.id))); + const saved = { + ...record, + sequence: Math.max(0, ...values.map((value) => value.sequence)) + 1, + }; + await requestValue(store.add(saved)); + return saved; + }), + put: (record) => + mutate(async (store) => { + decodeRecords([record], args.accountId, args.workspaceId); + const values = decodeRecords( + await requestValue(store.index('scope').getAll(scope)), + args.accountId, + args.workspaceId + ); + const bytes = [ + ...values.filter((value) => value.id !== record.id && value.stage !== 'delivered'), + record, + ].reduce( + (sum, value) => + sum + JSON.stringify(value.entry).length * 2 + (value.update?.byteLength ?? 0), + 0 + ); + if (bytes > MAX_PENDING_BYTES) throw new Error('Pending message storage is full'); + await requestValue(store.put(record)); + }), + remove: (id) => + mutate(async (store) => { + await requestValue(store.delete(key(id))); + }), + close: async () => { + closed = true; + if (opened) (await opened).close(); + }, + }; +} + +/** Cache repair must retain both the journal and every original replica it needs. */ +export async function hasPendingSessionSends( + factory: IDBFactory, + accountId?: string +): Promise { + const databases = await factory.databases?.(); + if (databases && !databases.some((database) => database.name === SESSION_SEND_DATABASE)) + return false; + const db = await new Promise((resolve, reject) => { + const request = factory.open(SESSION_SEND_DATABASE); + request.onupgradeneeded = () => { + const store = request.result.createObjectStore(STORE, { + keyPath: ['accountId', 'workspaceId', 'id'], + }); + store.createIndex('scope', ['accountId', 'workspaceId']); + store.createIndex('stage', 'stage'); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + if (db.version !== 1 || !db.objectStoreNames.contains(STORE)) return true; + const transaction = db.transaction(STORE, 'readonly'); + const done = transactionDone(transaction); + void done.catch(() => {}); + const store = transaction.objectStore(STORE); + if (accountId) { + const values = await requestValue(store.getAll()); + await done; + return values.some( + (record) => record.accountId === accountId && record.stage !== 'delivered' + ); + } + const [total, delivered] = await Promise.all([ + requestValue(store.count()), + requestValue(store.index('stage').count('delivered')), + ]); + await done; + return total > delivered; + } finally { + db.close(); + } +} diff --git a/packages/components/src/lib/session-send-journal.ts b/packages/components/src/lib/session-send-journal.ts new file mode 100644 index 000000000..f0eff4426 --- /dev/null +++ b/packages/components/src/lib/session-send-journal.ts @@ -0,0 +1,245 @@ +import type { SessionHistory, SessionId, SessionMeta } from '@lody/shared'; +import type { SessionSendResources } from './session-send-resources'; +import { throwIfSendAborted } from './session-send-resources'; + +export type SessionSendRecord = { + version: 1; + id: string; + sessionId: SessionId; + accountId: string; + workspaceId: string; + sourceReplica: string; + sequence: number; + entry: SessionHistory; + creation?: SessionMeta; + queue?: Record; + delivery: { kind: 'queue' | 'dispatch' } | { kind: 'guide'; expectedTurnId: string }; + stage: 'saved' | 'prepared' | 'committed' | 'delivered'; + /** Exact authored operations: replay imports these bytes, never appends again. */ + update?: Uint8Array; + error?: string; + guideOffer?: 'offered' | 'applied' | 'not-applied'; +}; + +export type SessionSendJournalStorage = { + list(): Promise; + insert(record: Omit): Promise; + put(record: SessionSendRecord): Promise; + remove(id: string): Promise; + close(): Promise; +}; + +export type SessionSendJournalPorts = { + resources: SessionSendResources; + storage: SessionSendJournalStorage; + observeExternal?(refresh: () => void): () => void; + notifyExternal?(): void; + /** Cross-window exclusion for the same account/workspace/session. */ + lock(key: string, signal: AbortSignal, execute: () => Promise): Promise; + /** Flush the source baseline, validate once, and prepare immutable CRDT operations. */ + prepare(record: SessionSendRecord, signal: AbortSignal): Promise; + /** Recover the original baseline, import exact operations and confirm local persistence. */ + commit(record: SessionSendRecord, signal: AbortSignal): Promise; + /** Resolve only on a target receipt; uncertainty keeps the committed record. */ + deliver( + record: SessionSendRecord, + signal: AbortSignal, + checkpoint: (patch: Pick) => Promise + ): Promise; +}; + +/** Durable stages are separate from transient fibers and UI subscription lifetimes. */ +export function createSessionSendJournal(ports: SessionSendJournalPorts) { + let snapshot: readonly SessionSendRecord[] = []; + const listeners = new Set<() => void>(); + const running = new Map>(); + let closed = false; + let refreshGeneration = 0; + const refresh = async () => { + const generation = ++refreshGeneration; + const records = await ports.storage.list(); + if (closed || generation !== refreshGeneration) return; + snapshot = records.sort((a, b) => a.sequence - b.sequence); + for (const listener of listeners) { + try { + listener(); + } catch (error) { + console.error('Pending message observer failed', error); + } + } + }; + + const unobserve = ports.observeExternal?.(() => { + void refresh().catch((error: unknown) => + console.error('Pending message refresh failed', error) + ); + }); + const changed = async () => { + await refresh(); + ports.notifyExternal?.(); + }; + + const workSession = (sessionId: SessionId): Promise => { + const existing = running.get(sessionId); + if (existing) return existing.then(() => workSession(sessionId)); + if (closed) return Promise.reject(new Error('Session send journal is closed')); + const work = ports.resources.run(async (signal) => { + await ports.lock(`submit:${sessionId}`, signal, async () => { + const records = (await ports.storage.list()) + .filter((record) => record.sessionId === sessionId) + .sort((a, b) => a.sequence - b.sequence); + for (let record of records) { + throwIfSendAborted(signal); + if (record.version !== 1) throw new Error('Unsupported session send record version'); + if (record.stage === 'delivered') continue; + try { + if (record.stage === 'saved') { + const update = await ports.prepare(record, signal); + throwIfSendAborted(signal); + record = { ...record, update, stage: 'prepared', error: undefined }; + // No externally visible mutation may precede this storage receipt. + await ports.storage.put(record); + } + if (record.stage === 'prepared') { + await ports.commit(record, signal); + record = { ...record, stage: 'committed', error: undefined }; + await ports.storage.put(record); + } + } catch (error) { + // Failed preparation/commit blocks later same-session submissions. + // Keep exact operations across lost acknowledgements and interruption. + await ports.storage.put({ + ...record, + error: error instanceof Error ? error.message : 'Submission interrupted', + }); + await changed(); + throw error; + } + await changed(); + } + }); + }); + running.set(sessionId, work); + void work + .finally(() => { + running.delete(sessionId); + }) + .catch(() => {}); + return work; + }; + + const deliver = (record: SessionSendRecord) => + ports.resources.run(async (signal) => { + await ports.lock(`delivery:${record.sessionId}`, signal, async () => { + let current = (await ports.storage.list()).find((item) => item.id === record.id); + if (!current || current.stage !== 'committed') return; + try { + await ports.deliver(current, signal, async (patch) => { + const next = { ...current!, ...patch }; + await ports.storage.put(next); + current = next; + }); + throwIfSendAborted(signal); + await ports.storage.put({ ...current, stage: 'delivered', error: undefined }); + } catch (error) { + await ports.storage.put({ + ...current, + error: error instanceof Error ? error.message : 'Delivery interrupted', + }); + throw error; + } finally { + await changed(); + } + }); + }); + + return { + getSnapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + refresh, + read: async (id: string) => (await ports.storage.list()).find((record) => record.id === id), + activate: async (id: string, delivery: SessionSendRecord['delivery']) => + ports.resources.run(async (signal) => { + const found = (await ports.storage.list()).find((record) => record.id === id); + if (!found) return undefined; + return ports.lock(`delivery:${found.sessionId}`, signal, async () => { + const current = (await ports.storage.list()).find((record) => record.id === id); + if (!current) return undefined; + if (JSON.stringify(current.delivery) === JSON.stringify(delivery)) return current; + if (current.guideOffer) + throw new Error('Guide outcome must be reconciled before changing delivery'); + const next = { + ...current, + delivery, + stage: current.stage === 'delivered' ? ('committed' as const) : current.stage, + }; + await ports.storage.put(next); + await changed(); + return next; + }); + }), + /** Admission means the complete record is on disk, not that the daemon received it. */ + accept: async (record: Omit) => { + if (closed) throw new Error('Session send journal is closed'); + let saved: SessionSendRecord | undefined; + try { + await ports.resources.run((signal) => + ports.lock('admission', signal, async () => { + saved = await ports.storage.insert({ ...record, stage: 'saved', version: 1 }); + }) + ); + } catch (error) { + // Interruption after the storage receipt cannot turn an accepted input + // back into an unsent composer draft. No submission is launched here. + if (!saved) throw error; + } + if (!saved) throw new Error('Recovery storage returned no admission receipt'); + const receipt = saved; + snapshot = [...snapshot.filter((item) => item.id !== receipt.id), receipt].sort( + (a, b) => a.sequence - b.sequence + ); + for (const listener of listeners) { + try { + listener(); + } catch (error) { + console.error('Pending message observer failed', error); + } + } + ports.notifyExternal?.(); + return saved; + }, + submit: workSession, + deliver, + retry: async (sessionId: SessionId) => { + await workSession(sessionId); + const records = await ports.storage.list(); + for (const record of records.filter( + (item) => item.sessionId === sessionId && item.stage === 'committed' + )) + await deliver(record); + }, + cancel: async (id: string) => + ports.resources.run(async (signal) => { + const found = (await ports.storage.list()).find((record) => record.id === id); + if (!found) return; + await ports.lock(`submit:${found.sessionId}`, signal, async () => { + const current = (await ports.storage.list()).find((record) => record.id === id); + if (current && current.stage !== 'saved') + throw new Error('Submission may already be accepted; reconcile before cancellation'); + await ports.storage.remove(id); + await changed(); + }); + }), + close: async () => { + closed = true; + unobserve?.(); + listeners.clear(); + await ports.storage.close(); + }, + }; +} diff --git a/packages/components/src/lib/session-send-resources.ts b/packages/components/src/lib/session-send-resources.ts index de3335612..611ee7af6 100644 --- a/packages/components/src/lib/session-send-resources.ts +++ b/packages/components/src/lib/session-send-resources.ts @@ -54,6 +54,7 @@ export function createSessionSendResources(stores: { ) ); let closing: Promise | undefined; + let activeOperations = 0; const run = async ( work: (signal: AbortSignal) => Promise, @@ -86,14 +87,21 @@ export function createSessionSendResources(stores: { } }; + const runTracked = async (work: (signal: AbortSignal) => Promise, signal?: AbortSignal): Promise => { + activeOperations += 1; + try { return await run(work, signal); } + finally { activeOperations -= 1; } + }; + return { - run, + run: runTracked, + getActiveCount: () => activeOperations, withSessionStore: ( sessionId: SessionId, use: (store: SessionDocStore, signal: AbortSignal) => Promise | A, signal?: AbortSignal ): Promise => - run(async (ownedSignal) => { + runTracked(async (ownedSignal) => { const store = await stores.acquire(sessionId); try { throwIfSendAborted(ownedSignal); diff --git a/packages/components/src/lib/session-submission.ts b/packages/components/src/lib/session-submission.ts index 0b6edc313..4c07efbef 100644 --- a/packages/components/src/lib/session-submission.ts +++ b/packages/components/src/lib/session-submission.ts @@ -1,3 +1,4 @@ +import { acceptSessionUserTurn } from './session-send-admission'; import type { SessionHistory, SessionHistoryInput, @@ -231,17 +232,7 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { void runtime.ensureDocStream(sessionRoomId).catch((error: unknown) => { console.warn('Failed to pre-create session doc stream', { sessionId, error }); }); - await runtime.writer.startSession( - sessionId, - sessionMeta as unknown as Record, - historyEntry, - { - userTurnId: historyEntry.id, - userId, - timestamp, - inputConfig: inputConfig as unknown as Record, - } - ); + await acceptSessionUserTurn(runtime, sessionId, historyEntry, { kind: 'dispatch' }, sessionMeta); publishSessionMeta(sessionRoomId, sessionMeta); recordChat(sessionMeta, sessionId, true, history.items); return { sessionId, sessionMeta, historyEntry }; @@ -250,7 +241,7 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { const addSessionHistory = async ( sessionId: SessionId, history: Omit, - options?: { dispatch?: boolean } + options?: { dispatch?: boolean; guideExpectedTurnId?: string } ) => { if (!runtime) { throw new Error('Runtime not ready'); @@ -288,7 +279,12 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { inputConfig: inputConfig as unknown as Record, }; } - await runtime.writer.appendSessionTurn(sessionId, entry, dispatch); + if (entry.role === 'user') { + await acceptSessionUserTurn(runtime, sessionId, entry, + options?.guideExpectedTurnId ? { kind: 'guide', expectedTurnId: options.guideExpectedTurnId } : { kind: options?.dispatch ? 'dispatch' : 'queue' }); + } else { + await runtime.writer.appendSessionTurn(sessionId, entry, dispatch); + } // session/chat fires once for every user message dispatched through Lody — // the session-creating turn AND every follow-up — so it tracks active-use // frequency, unlike session/start_success which only covers creation. This @@ -309,6 +305,13 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { if (!runtime) { throw new Error('Runtime not ready'); } + const saved = await runtime.sendJournal?.read(userTurnId); + if (saved) { + const active = await runtime.sendJournal!.activate(userTurnId, { kind: 'dispatch' }); + await runtime.sendJournal!.submit(sessionId); + if (active) await runtime.sendJournal!.deliver(active); + return; + } const entry = await runtime.sendResources.withSessionStore(sessionId, async (sessionStore) => { const read = await sessionStore.sessionData.history.readTurn(userTurnId); return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; @@ -380,6 +383,19 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { if (!runtime) { throw new Error('Runtime not ready'); } + const saved = await runtime.sendJournal?.read(userTurnId); + if (saved) { + const active = await runtime.sendJournal!.activate(userTurnId, { kind: 'guide', expectedTurnId }); + await runtime.sendJournal!.submit(sessionId); + if (active) await runtime.sendJournal!.deliver(active); + const completed = await runtime.sendJournal!.read(userTurnId); + if (completed?.guideOffer === 'applied') { + onRpcDelivered(sessionId, userTurnId); + return true; + } + if (completed?.guideOffer === 'not-applied') return false; + throw new Error('Guide outcome is uncertain; the original message is retained'); + } const entry = await runtime.sendResources.withSessionStore(sessionId, async (sessionStore) => { const read = await sessionStore.sessionData.history.readTurn(userTurnId); return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; @@ -478,7 +494,7 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { userTurnId, response ? `${response.disposition}${response.error ? `: ${response.error}` : ''}` : 'timeout' ); - return false; + throw new Error('Guide outcome is uncertain; the original message is retained'); }; return { diff --git a/packages/components/src/providers/AGENTS.md b/packages/components/src/providers/AGENTS.md index 1cc39de03..9c43d6121 100644 --- a/packages/components/src/providers/AGENTS.md +++ b/packages/components/src/providers/AGENTS.md @@ -95,3 +95,5 @@ These rules also bind attachment helpers and UI callers. Noncancelable IPC must settle before release; only the cache disposes stores. - Cancellation reaches underlying I/O and fences late completion; it never authorizes a fallback upload. Await multipart cleanup before returning failure. + +- User-message admission uses the account/workspace send journal and one HistoryWriter. Save exact prepared operations before live import; recovery must not re-append. Serialize submission and delivery separately per session, and explicitly synchronize imported operations before retiring recovery records. diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 2a23f6711..2b89bb39d 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -1,3 +1,5 @@ +import { createWorkspaceSessionSendJournal } from './workspace-session-send-journal'; +import { throwIfSendAborted } from '../lib/session-send-resources'; import { createSessionSendResources } from '@/lib/session-send-resources'; import { jotaiStore } from '@/lib/utils'; import { desktopWindowId } from '@/lib/desktop-window'; @@ -166,6 +168,7 @@ export function resolveWorkspaceRuntimeCacheIdentity( } type RuntimeDeps = { + accountId?: string | null; /** * Used for caching the (slug, id) mapping in localStorage. */ @@ -4433,6 +4436,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise { // Cancel and join send I/O while its cache, transport and repo still exist. await sendResources.dispose(); + await sendJournal?.close(); cancelDelayedBackgroundSyncStart?.(); cancelDelayedBackgroundSyncStart = null; cancelDelayedStartupAcpCapabilitiesRefresh?.(); @@ -4618,10 +4622,36 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise { + await waitForPromiseOrAbort(transportReady.promise, signal); + throwIfSendAborted(signal); + await targetRouter.prepareSessionTarget(sessionId); + throwIfSendAborted(signal); + const roomId = getSessionRoomId(sessionId); + const plane = targetRouter.getReadinessTransportForRoom({ kind: 'doc', id: roomId }); + // Imported prepared operations do not emit subscribeLocalUpdates. Explicit + // sync exports the missing operations and reuses the transport's room. + // Upstream sync races its AbortSignal without joining raw stream.sync(); + // omit that signal here so our owner retains dependencies until it settles. + const report = await repo.sync({ scope: 'full', docIds: [roomId], flockDocIds: [], requireTransports: [plane] }); + throwIfSendAborted(signal); + if (!report.transports.some((transport) => transport.transportId === plane && transport.ok) || + targetRouter.getReadinessTransportForRoom({ kind: 'doc', id: roomId }) !== plane) { + throw new Error('Target synchronization is not confirmed'); + } + }, + }) : null; return { workspaceSlug: deps.workspaceSlug, workspaceId, repo, + sourceReplica: cacheIdentity.repoDbName, + accountId: deps.accountId ?? null, + sendJournal, codeCollabFileIndexCache, sendResources, writer: workspaceWriter, diff --git a/packages/components/src/providers/runtime-provider.tsx b/packages/components/src/providers/runtime-provider.tsx index 8f7f92760..a70b4f339 100644 --- a/packages/components/src/providers/runtime-provider.tsx +++ b/packages/components/src/providers/runtime-provider.tsx @@ -1,8 +1,9 @@ +import { SessionSendRecovery } from '../components/chat/session-send-recovery'; import { useEffect, useRef, type ReactNode } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { LODY_PRESENCE_HEARTBEAT_MS, type MachineId, type WorkspaceId } from '@lody/shared'; import { authTokenAtom, runtimeAtom } from '@/atoms/runtime'; -import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from '@/atoms'; +import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom, userAtom } from '@/atoms'; import { clearDocMetaCacheAtom, docMetaSubscriptionAtom } from '@/atoms/doc-meta'; import { clearLodyPresenceStatesAtom, @@ -71,6 +72,8 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { const localProbeAttempted = useAtomValue(localProbeAttemptedAtom); const localAgentEnabled = useAtomValue(localAgentEnabledAtom); const token = useAtomValue(authTokenAtom); + const currentUser = useAtomValue(userAtom); + const previousShutdown = useRef>(Promise.resolve()); const runtime = useAtomValue(runtimeAtom); const setRuntime = useSetAtom(runtimeAtom); const setControlConnectionState = useSetAtom(lodyControlConnectionStateAtom); @@ -113,6 +116,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { // Local (open-source) platform: the effective workspace id is the CLI's // implicit workspace — no cached/server id arbitration, no auth involved. const isLocalPlatform = platform.sync.mode === 'local'; + const accountId = currentUser?.id ?? (isLocalPlatform ? 'local' : null); const telemetryEnabled = platform.capabilities.has('telemetry'); const implicitLocalWorkspace = useImplicitLocalWorkspace(); const { ready: localAgentRuntimeReady } = resolveCloudPlatformRuntimePolicy({ @@ -233,8 +237,10 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { const { workspaceIdSource } = workspaceIdResolutionLogRef.current; setControlConnectionState('idle'); - void (async () => { + const initialization = (async () => { try { + await previousShutdown.current; + if (disposed) return; // If the user requested a cache clear before the last reload, delete all // lody* IndexedDB + Cache Storage now — before the runtime opens the repo // DB, while nothing holds those databases open. No-op on normal boots. @@ -253,6 +259,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { eagerSyncSurface, }); workspaceRuntime = await createWorkspaceRuntime({ + accountId, workspaceSlug, workspaceId: effectiveWorkspaceId, apiBaseUrl: API_BASE_URL, @@ -346,13 +353,15 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { setRuntimeInitializing(true); clearDocMetaCache(); clearPresenceStates(); - if (workspaceRuntime) { - void workspaceRuntime.dispose().catch((error: unknown) => { - logRuntimeOperationError('cleanup dispose', error); - }); - } + previousShutdown.current = initialization.then(async () => { + if (workspaceRuntime) await workspaceRuntime.dispose(); + }); + void previousShutdown.current.catch((error: unknown) => { + logRuntimeOperationError('cleanup dispose', error); + }); }; }, [ + accountId, clearDocMetaCache, clearPresenceStates, isLocalPlatform, @@ -373,6 +382,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { setControlConnectionState('idle'); return; } + if (runtime.accountId !== accountId) return; if (!token) { setControlConnectionState('idle'); void runtime.setAuthToken(null).catch((error: unknown) => { @@ -383,7 +393,7 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { void runtime.setAuthToken(token).catch((error: unknown) => { logRuntimeOperationError('set auth token', error); }); - }, [runtime, setControlConnectionState, token]); + }, [accountId, runtime, setControlConnectionState, token]); useEffect(() => { if (!runtime || !localProbeAttempted) { @@ -406,5 +416,5 @@ export function RuntimeProvider({ children }: { children: ReactNode }) { }; }, [setBrowserOnline]); - return children; + return <>{children}; } diff --git a/packages/components/src/providers/workspace-session-send-journal.ts b/packages/components/src/providers/workspace-session-send-journal.ts new file mode 100644 index 000000000..526e329a0 --- /dev/null +++ b/packages/components/src/providers/workspace-session-send-journal.ts @@ -0,0 +1,267 @@ +import { + getSessionRoomId, + isLoroRepoDocDeleted, + normalizeSessionTurnInputConfig, + type MachineId, + type SessionId, + type SessionMeta, +} from '@lody/shared'; +import { IndexedDBStorageAdaptor } from 'loro-repo/storage/indexeddb'; +import type { WorkspaceRuntime } from '../atoms/runtime'; +import { createSessionSendJournal, type SessionSendRecord } from '../lib/session-send-journal'; +import { + createSessionSendJournalStorage, + SESSION_SEND_STORAGE_LOCK, +} from '../lib/session-send-journal-storage'; +import { throwIfSendAborted } from '../lib/session-send-resources'; + +export function createWorkspaceSessionSendJournal(args: { + accountId: string; + sourceReplica: string; + runtime: Pick< + WorkspaceRuntime, + | 'workspaceId' + | 'repo' + | 'writer' + | 'sendResources' + | 'requestSessionDispatchTurn' + | 'requestSessionSteer' + >; + waitForTargetSync(sessionId: SessionId, signal: AbortSignal): Promise; +}) { + const { runtime, accountId } = args; + const storage = createSessionSendJournalStorage({ accountId, workspaceId: runtime.workspaceId }); + const requireAvailable = async (record: SessionSendRecord) => { + if (record.accountId !== accountId || record.workspaceId !== runtime.workspaceId) + throw new Error('Submission belongs to another account or workspace'); + // A new window can have a fresh replica. Merge persisted metadata before + // deciding that a saved target is absent, using the repo's existing CRDT. + if (record.sourceReplica !== args.sourceReplica) { + const original = new IndexedDBStorageAdaptor({ dbName: record.sourceReplica }); + try { + const baseline = await original.loadMeta(); + if (!baseline) + throw new Error('Original submission metadata is unavailable; recovery retained'); + runtime.repo.getMeta().importJson(baseline.exportJson()); + } finally { + await original.close(); + } + } + const found = await runtime.repo.getDocMeta(getSessionRoomId(record.sessionId)); + const meta = found?.meta as SessionMeta | undefined; + if (isLoroRepoDocDeleted(found) || meta?.isArchived) + throw new Error('Target conversation was deleted or archived'); + if (!record.creation && !meta?.id) + throw new Error('Target conversation is not available in this replica'); + return meta; + }; + let notify = () => {}; + return createSessionSendJournal({ + resources: runtime.sendResources, + storage, + observeExternal: (refresh) => { + if (typeof BroadcastChannel === 'undefined') return () => {}; + const channel = new BroadcastChannel( + `lody-session-send:${JSON.stringify([accountId, runtime.workspaceId])}` + ); + channel.onmessage = refresh; + notify = () => channel.postMessage(null); + return () => { + notify = () => {}; + channel.close(); + }; + }, + notifyExternal: () => notify(), + lock: async (key, signal, execute) => { + if (!navigator.locks) + throw new Error( + 'This application cannot safely coordinate pending messages across windows' + ); + return navigator.locks.request( + key === 'admission' + ? SESSION_SEND_STORAGE_LOCK + : `lody-session-send:${JSON.stringify([accountId, runtime.workspaceId, key])}`, + { signal }, + execute + ); + }, + prepare: async (record, signal) => { + await requireAvailable(record); + return runtime.sendResources.withSessionStore( + record.sessionId, + async (store) => { + const current = await store.sessionData.history.readTurn(record.id); + if (current.state !== 'missing') + throw new Error( + 'Submission identity already exists; original operations are required for recovery' + ); + const update = record.queue + ? await runtime.writer.prepareSessionMessage(record.sessionId, record.queue) + : await store.sessionData.commands.prepareAppendTurn(record.entry); + // Captured dependencies must be persisted before publishing the prepared update. + await runtime.repo.flush(); + throwIfSendAborted(signal); + return update; + }, + signal + ); + }, + commit: async (record, signal) => { + const meta = await requireAvailable(record); + await runtime.sendResources.withSessionStore( + record.sessionId, + async (store) => { + if (record.sourceReplica !== args.sourceReplica) { + // Recover the original persisted baseline, never infer absence from a new window. + const original = new IndexedDBStorageAdaptor({ dbName: record.sourceReplica }); + try { + const source = await original.loadDoc(getSessionRoomId(record.sessionId)); + if (!source) + throw new Error('Original submission replica is unavailable; recovery retained'); + try { + store.doc.import(source.export({ mode: 'snapshot' })); + } finally { + source.free(); + } + } finally { + await original.close(); + } + } + throwIfSendAborted(signal); + if (!record.update) throw new Error('Prepared submission has no saved operations'); + await store.sessionData.commands.applyPreparedTurn(record.update); + if (record.queue) + await runtime.writer.upsertDocMeta(getSessionRoomId(record.sessionId), { + messageQueueUpdatedAt: Date.now(), + }); + if (record.creation) { + // Repair only absent creation fields after a partial write; retain later edits. + const patch = Object.fromEntries( + Object.entries(record.creation).filter(([key]) => !meta || !(key in meta)) + ); + if (Object.keys(patch).length) + await runtime.writer.upsertDocMeta(getSessionRoomId(record.sessionId), patch); + } + await runtime.repo.flush(); + }, + signal + ); + }, + deliver: async (record, signal, checkpoint) => { + const meta = await requireAvailable(record); + const machineId = meta?.machineId ?? record.creation?.machineId; + if (!machineId) throw new Error('Target machine is unavailable'); + const inputConfig = normalizeSessionTurnInputConfig(record.entry.inputConfig); + const userId = record.entry.userId?.trim(); + if (!inputConfig || !userId) + throw new Error('Saved submission has invalid input configuration'); + let dispatch = record.delivery.kind === 'dispatch'; + if (record.delivery.kind === 'guide') { + let offer = record.guideOffer; + if (offer === 'offered') { + const read = await runtime.sendResources.withSessionStore( + record.sessionId, + (store) => store.sessionData.history.readTurn(record.id), + signal + ); + if (read.state !== 'ready' || !read.turn.status || read.turn.status === 'pending_apply') { + throw new Error('Guide outcome is uncertain; retry only reconciles the original turn'); + } + offer = + normalizeSessionTurnInputConfig(read.turn.inputConfig)?._lodyDeliveryKind === 'steer' + ? 'applied' + : 'not-applied'; + await checkpoint({ guideOffer: offer }); + } + if (!offer) { + await checkpoint({ guideOffer: 'offered' }); + const [rpc, sync] = await Promise.allSettled([ + runtime.requestSessionSteer(machineId as MachineId, { + sessionId: record.sessionId, + expectedTurnId: record.delivery.expectedTurnId, + userTurnId: record.id, + userId, + timestamp: record.entry.timestamp, + inputConfig, + }), + args.waitForTargetSync(record.sessionId, signal), + ]); + if (rpc.status === 'rejected') throw rpc.reason; + if (rpc.value?.applied) offer = 'applied'; + else if (rpc.value?.disposition === 'no-active-turn') offer = 'not-applied'; + else throw new Error('Guide outcome is uncertain; the original turn is retained'); + await checkpoint({ guideOffer: offer }); + if (sync.status === 'rejected') throw sync.reason; + } + throwIfSendAborted(signal); + await runtime.sendResources.withSessionStore( + record.sessionId, + (store) => + store.sessionData.commands.applyHistoryAction({ + kind: 'user-status', + turnId: record.id, + status: offer === 'applied' ? 'processing' : 'pending', + deliveredSteer: offer === 'applied', + onlyPendingApply: true, + }), + signal + ); + await runtime.repo.flush(); + dispatch = offer === 'not-applied'; + } + if (dispatch) { + // Recheck under the session delivery lock: UI state can predate another send. + dispatch = await runtime.sendResources.withSessionStore( + record.sessionId, + async (store) => { + if ((store.getState().mq ?? []).some((item) => item.userTurnId !== record.id)) + return false; + const rows = await store.sessionData.history.readDirectory( + 0, + await store.sessionData.history.count() + ); + const position = rows.findIndex((row) => row.turnId === record.id); + if (position < 0 || rows[position]?.state !== 'ready') + throw new Error('Saved turn is not available for dispatch'); + const status = rows[position]?.scalars?.status; + if (status && status !== 'pending') return false; + return !rows + .slice(0, position) + .some( + (row) => + row.state !== 'ready' || + (row.scalars?.role === 'user' && + ['pending', 'pending_apply', 'seen', 'processing'].includes( + row.scalars.status ?? 'pending' + )) || + (row.scalars?.role === 'assistant' && !row.scalars.finished) + ); + }, + signal + ); + } + if (dispatch) { + await runtime.writer.upsertDocMeta(getSessionRoomId(record.sessionId), { + latestUserMsgId: record.id, + }); + await runtime.repo.flush(); + throwIfSendAborted(signal); + // RPC accelerates already-persisted history; its acknowledgment alone never retires the journal. + const [synced] = await Promise.allSettled([ + args.waitForTargetSync(record.sessionId, signal), + runtime.requestSessionDispatchTurn(machineId as MachineId, { + sessionId: record.sessionId, + userTurnId: record.id, + userId, + timestamp: record.entry.timestamp, + inputConfig, + }), + ]); + if (synced.status === 'rejected') throw synced.reason; + throwIfSendAborted(signal); + return; + } + await args.waitForTargetSync(record.sessionId, signal); + }, + }); +} diff --git a/packages/components/src/providers/workspace-writer-impl.ts b/packages/components/src/providers/workspace-writer-impl.ts index 88bec2a1d..13d4c1ae7 100644 --- a/packages/components/src/providers/workspace-writer-impl.ts +++ b/packages/components/src/providers/workspace-writer-impl.ts @@ -1,3 +1,4 @@ +import { createConversationSession } from '../lib/conversation-view'; import { applyPreviewVisualCommentMutation, getServerNow, @@ -171,6 +172,18 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo }); }, + async prepareSessionMessage(sessionId, item) { + return withSessionStore(sessionId, (store) => { + const from = store.doc.version(); + const fork = store.doc.fork(); + const prepared = createConversationSession(fork, { sessionId: sessionId as SessionId }); + try { + prepared.mirror.setState((draft) => ({ ...draft, mq: [...(draft.mq ?? []), item as MessageQueueItem] })); + return fork.export({ mode: 'update', from }); + } finally { prepared.dispose(); fork.free(); from.free(); } + }); + }, + async enqueueSessionMessage(sessionId, item) { await withSessionStore(sessionId, (store) => { store.setState((draft: SessionDocDraft) => { diff --git a/packages/components/src/providers/workspace-writer.ts b/packages/components/src/providers/workspace-writer.ts index 029433be0..c1e5a9110 100644 --- a/packages/components/src/providers/workspace-writer.ts +++ b/packages/components/src/providers/workspace-writer.ts @@ -111,6 +111,7 @@ export interface WorkspaceWriter { ): Promise; /** Message-queue mutations (durable CRDT on the session doc). */ + prepareSessionMessage(sessionId: string, item: Record): Promise; enqueueSessionMessage(sessionId: string, item: Record): Promise; removeSessionMessage(sessionId: string, itemId: string): Promise; updateSessionMessage( diff --git a/packages/components/src/routes/__root.tsx b/packages/components/src/routes/__root.tsx index a34d8deeb..90714e263 100644 --- a/packages/components/src/routes/__root.tsx +++ b/packages/components/src/routes/__root.tsx @@ -336,7 +336,7 @@ function RootLocationEffects() { setAuthToken(null); setWorkspaceContext({ slug: null, workspaceId: null }); - void signOutWithoutRedirect(authClient); + void signOutWithoutRedirect(authClient, { sessionExpired: true }); toast.error(i18next.t('login.sessionExpired')); void navigate({ to: '/login', diff --git a/packages/components/src/routes/join/$token.tsx b/packages/components/src/routes/join/$token.tsx index c63cf3372..11dcf8ebb 100644 --- a/packages/components/src/routes/join/$token.tsx +++ b/packages/components/src/routes/join/$token.tsx @@ -22,8 +22,7 @@ export function WorkspaceJoinRequestRoute() { onSignInRequested={goToLogin} onEmailVerificationRequested={() => { void (async () => { - await signOutWithoutRedirect(authClient); - goToLogin(); + if ((await signOutWithoutRedirect(authClient)).ok) goToLogin(); })(); }} onWorkspaceRequested={(workspaceSlug) => { diff --git a/packages/components/tests/session-send-journal.test.ts b/packages/components/tests/session-send-journal.test.ts new file mode 100644 index 000000000..49c2bb4ca --- /dev/null +++ b/packages/components/tests/session-send-journal.test.ts @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { LoroDoc } from 'loro-crdt'; +import { createHistoryWriter, type SessionHistory, type SessionId } from '@lody/shared'; +import { + createSessionSendResources, + type SessionSendResources, +} from '../src/lib/session-send-resources'; +import { + createSessionSendJournal, + type SessionSendJournalPorts, + type SessionSendJournalStorage, + type SessionSendRecord, +} from '../src/lib/session-send-journal'; + +const owners: SessionSendResources[] = []; +afterEach(async () => { + await Promise.all(owners.splice(0).map((owner) => owner.dispose())); +}); +const record = (id: string, sessionId = 'session') => ({ + id, + sessionId: sessionId as SessionId, + accountId: 'account', + workspaceId: 'workspace', + sourceReplica: 'original', + entry: { + id, + role: 'user', + timestamp: '2026-01-01T00:00:00Z', + items: [{ type: 'text', text: id }], + fileDiff: [], + } as SessionHistory, + delivery: { kind: 'dispatch' as const }, +}); +function memoryStorage() { + const records = new Map(); + const storage: SessionSendJournalStorage = { + list: async () => structuredClone([...records.values()]), + insert: async (input) => { + const value = { ...input, sequence: records.size + 1 }; + records.set(value.id, structuredClone(value)); + return value; + }, + put: async (value) => { + records.set(value.id, structuredClone(value)); + }, + remove: async (id) => { + records.delete(id); + }, + close: async () => {}, + }; + return storage; +} +function fixture(overrides: Partial = {}) { + const doc = new LoroDoc(); + const writer = createHistoryWriter(doc); + const resources = createSessionSendResources({ + acquire: async () => { + throw new Error('Unexpected borrow'); + }, + releaseRef: () => {}, + }); + owners.push(resources); + const storage = memoryStorage(); + const ports: SessionSendJournalPorts = { + resources, + storage, + lock: async (_key, _signal, execute) => execute(), + prepare: async (value) => writer.prepareAppend(value.entry), + commit: async (value) => { + writer.applyPrepared(value.update!); + }, + deliver: async () => {}, + ...overrides, + }; + return { doc, writer, ports, journal: createSessionSendJournal(ports) }; +} + +describe('persistent submission stages', () => { + it('replays exactly the saved operations after an applied write loses its acknowledgment', async () => { + const f = fixture(); + let loseAck = true; + f.ports.commit = async (value) => { + f.writer.applyPrepared(value.update!); + if (loseAck) { + loseAck = false; + throw new Error('Lost local receipt'); + } + }; + await f.journal.accept(record('fixed')); + await expect(f.journal.submit('session' as SessionId)).rejects.toThrow('Lost local receipt'); + expect((await f.ports.storage.list())[0]?.stage).toBe('prepared'); + expect(f.writer.readStored().map((turn) => turn.id)).toEqual(['fixed']); + + // Simulate a new service using the same persisted intent after restart. + const recovered = createSessionSendJournal(f.ports); + await recovered.retry('session' as SessionId); + expect(f.writer.readStored().map((turn) => turn.id)).toEqual(['fixed']); + expect((await f.ports.storage.list())[0]?.stage).toBe('delivered'); + }); + + it('publishes nothing when saving the prepared operation fails', async () => { + const f = fixture(); + const put = f.ports.storage.put; + f.ports.storage.put = async (value) => { + if (value.stage === 'prepared') throw new Error('Disk full'); + await put(value); + }; + await f.journal.accept(record('fixed')); + await expect(f.journal.submit('session' as SessionId)).rejects.toThrow('Disk full'); + expect(f.writer.readStored()).toEqual([]); + expect((await f.ports.storage.list())[0]?.stage).toBe('saved'); + }); + + it('blocks later same-session writes behind a failed head but allows another session', async () => { + const f = fixture(); + const prepare = f.ports.prepare; + f.ports.prepare = async (value, signal) => { + if (value.id === 'first') throw new Error('Preparation failed'); + return prepare(value, signal); + }; + await f.journal.accept(record('first')); + await f.journal.accept(record('second')); + await f.journal.accept(record('independent', 'other')); + await expect(f.journal.submit('session' as SessionId)).rejects.toThrow('Preparation failed'); + await f.journal.submit('other' as SessionId); + expect(f.writer.readStored().map((turn) => turn.id)).toEqual(['independent']); + const saved = await f.ports.storage.list(); + expect(saved.find((value) => value.id === 'second')?.stage).toBe('saved'); + }); + + it('retains committed content when target delivery is uncertain', async () => { + const f = fixture({ + deliver: async () => { + throw new Error('Target disconnected'); + }, + }); + await f.journal.accept(record('fixed')); + await expect(f.journal.retry('session' as SessionId)).rejects.toThrow('Target disconnected'); + expect((await f.ports.storage.list())[0]).toMatchObject({ + stage: 'committed', + error: 'Target disconnected', + }); + await expect(f.journal.cancel('fixed')).rejects.toThrow(/already be accepted/); + expect(f.writer.readStored().map((turn) => turn.id)).toEqual(['fixed']); + }); +}); + +describe('IndexedDB recovery receipts', () => { + it('restores exact prepared bytes after closing and reopening storage', async () => { + const { IDBFactory } = await import('fake-indexeddb'); + const { createSessionSendJournalStorage } = + await import('../src/lib/session-send-journal-storage'); + const indexedDB = new IDBFactory(); + const args = { accountId: 'account', workspaceId: 'workspace', indexedDB }; + const first = createSessionSendJournalStorage(args); + const saved = await first.insert({ ...record('fixed'), version: 1, stage: 'saved' }); + await first.put({ ...saved, stage: 'prepared', update: new Uint8Array([1, 2, 3]) }); + await first.close(); + const recovered = createSessionSendJournalStorage(args); + expect(await recovered.list()).toEqual([ + { ...saved, stage: 'prepared', update: new Uint8Array([1, 2, 3]) }, + ]); + await recovered.close(); + }); + + it('serializes concurrent window admissions and separates account storage', async () => { + const { IDBFactory } = await import('fake-indexeddb'); + const { createSessionSendJournalStorage } = + await import('../src/lib/session-send-journal-storage'); + const indexedDB = new IDBFactory(); + const args = { accountId: 'account', workspaceId: 'workspace', indexedDB }; + const first = createSessionSendJournalStorage(args); + const peer = createSessionSendJournalStorage(args); + const other = createSessionSendJournalStorage({ ...args, accountId: 'other-account' }); + const admitted = await Promise.all([ + first.insert({ ...record('first'), version: 1, stage: 'saved' }), + peer.insert({ ...record('second'), version: 1, stage: 'saved' }), + ]); + expect(new Set(admitted.map((value) => value.sequence)).size).toBe(2); + expect((await first.list()).map((value) => value.id).sort()).toEqual(['first', 'second']); + expect(await other.list()).toEqual([]); + await Promise.all([first.close(), peer.close(), other.close()]); + }); +}); + +it('exports imported prepared operations to the actual Streams adapter without reauthoring', async () => { + const { createLoroDocAdapter } = await import('@loro-dev/streams-crdt/loro'); + const local = new LoroDoc(); + const remote = new LoroDoc(); + const writer = createHistoryWriter(local); + const adapter = createLoroDocAdapter(local); + const target = createLoroDocAdapter(remote); + const stop = adapter.subscribeLocalUpdates((batch) => { + void target.applyRemoteUpdates(batch.updates, target.emptyVersion()); + }); + try { + writer.applyPrepared(writer.prepareAppend(record('prepared').entry)); + expect(createHistoryWriter(remote).readStored()).toEqual([]); + const missing = adapter.exportUpdates(target.emptyVersion()); + expect(missing).toBeTruthy(); + if (!missing) throw new Error('Prepared operations missing from explicit synchronization'); + await target.applyRemoteUpdates(missing.updates, target.emptyVersion()); + await target.applyRemoteUpdates(missing.updates, target.emptyVersion()); + expect( + createHistoryWriter(remote) + .readStored() + .map((turn) => turn.id) + ).toEqual(['prepared']); + } finally { + stop(); + local.free(); + remote.free(); + } +}); + +it('holds the session delivery lock until raw delivery settles', async () => { + const firstStarted = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const secondWaiting = Promise.withResolvers(); + const locks = new Map>(); + const delivered: string[] = []; + const f = fixture({ + lock: async (key, _signal, execute) => { + const previous = locks.get(key) ?? Promise.resolve(); + const release = Promise.withResolvers(); + locks.set( + key, + previous.then(() => release.promise) + ); + if (key === 'delivery:session' && locks.size && delivered.includes('first')) + secondWaiting.resolve(); + await previous; + try { + return await execute(); + } finally { + release.resolve(); + } + }, + deliver: async (value) => { + delivered.push(value.id); + if (value.id === 'first') { + firstStarted.resolve(); + await releaseFirst.promise; + } + }, + }); + await f.journal.accept(record('first')); + await f.journal.accept(record('second')); + await f.journal.submit('session' as SessionId); + const values = await f.ports.storage.list(); + const first = f.journal.deliver(values[0]!); + await firstStarted.promise; + const second = f.journal.deliver(values[1]!); + await secondWaiting.promise; + expect(delivered).toEqual(['first']); + releaseFirst.resolve(); + await Promise.all([first, second]); + expect(delivered).toEqual(['first', 'second']); +}); diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index 89cc8f1cb..c45c0cb04 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -1,3 +1,6 @@ +import { LoroDoc } from 'loro-crdt'; +import { createHistoryWriter } from '@lody/shared'; +import { createSessionSendJournal, type SessionSendRecord } from '../src/lib/session-send-journal'; import { createSessionSendResources, type SessionSendResources } from '../src/lib/session-send-resources'; import { applyHistoryAction } from '../../shared/src/session-data/history-actions'; import type { HistoryAction, SessionEntry } from '@lody/shared/session-data'; @@ -202,6 +205,8 @@ const createRuntime = ( } as unknown as WorkspaceRuntime['writer']); const runtime = { + accountId: 'user-1', + sourceReplica: 'synthetic-replica', workspaceSlug: overrides.workspaceSlug ?? 'workspace-slug', workspaceId: overrides.workspaceId ?? ('workspace-1' as WorkspaceId), repo, @@ -225,6 +230,27 @@ const createRuntime = ( }); sendResourceOwners.add(resources); Object.defineProperty(runtime, 'sendResources', { value: resources }); + const records = new Map(); + const historyDoc = new LoroDoc(); + const historyWriter = createHistoryWriter(historyDoc); + const journal = createSessionSendJournal({ + resources, + storage: { + list: async () => structuredClone([...records.values()]), + insert: async (input) => { const saved = { ...input, sequence: records.size + 1 }; records.set(saved.id, saved); return saved; }, + put: async (value) => { records.set(value.id, value); }, + remove: async (id) => { records.delete(id); }, + close: async () => {}, + }, + lock: async (_key, _signal, execute) => execute(), + prepare: async (value) => historyWriter.prepareAppend(value.entry), + commit: async (value) => { + historyWriter.applyPrepared(value.update!); + sessionHistory.splice(0, sessionHistory.length, ...historyWriter.readStored()); + }, + deliver: async () => {}, + }); + Object.defineProperty(runtime, 'sendJournal', { value: journal }); return runtime; }; @@ -837,11 +863,8 @@ describe('useSessionActions', () => { // A resend rides the ordinary send path: identical content, brand-new id. expect(second.id).not.toBe(first.id); - expect(appendSessionTurn).toHaveBeenCalledTimes(2); - const resentEntry = appendSessionTurn.mock.calls[1]?.[1] as { - inputConfig?: { inputBlocks?: unknown }; - }; - expect(resentEntry.inputConfig?.inputBlocks).toEqual(inputBlocks); + const resent = await runtime.withSessionStore(sessionId, (store) => store.sessionData.history.readTurn(second.id)); + expect(resent).toMatchObject({ state: 'ready', turn: { inputConfig: { inputBlocks } } }); }); it('preserves the initial history and activity through the extracted submission service', async () => { @@ -913,7 +936,8 @@ describe('useSessionActions', () => { } as unknown as Parameters[1] ); - const meta = startSession.mock.calls[0]![1]; + const saved = runtime.sendJournal!.getSnapshot().find((record) => record.sessionId === sessionId); + const meta = saved!.creation!; expect(meta).not.toHaveProperty('baseBranch'); expect(meta.project).toMatchObject({ branch: selector }); }); @@ -1157,7 +1181,7 @@ describe('useSessionActions', () => { await expect( actions.requestSessionSteer(sessionId, 'assistant:user-1', userTurnId, { machineId }) - ).resolves.toBe(false); + ).rejects.toThrow('Guide outcome is uncertain'); expect(history[0]).toMatchObject({ status: 'pending_apply' }); expect(setState).not.toHaveBeenCalled(); diff --git a/packages/components/tests/workspace-join-request-route.test.tsx b/packages/components/tests/workspace-join-request-route.test.tsx index 0f91f66ae..ed0a0c1ef 100644 --- a/packages/components/tests/workspace-join-request-route.test.tsx +++ b/packages/components/tests/workspace-join-request-route.test.tsx @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ user: { email: string }; }, authClient: { id: 'auth-client' }, - signOutWithoutRedirect: vi.fn(), + signOutWithoutRedirect: vi.fn().mockResolvedValue(true), })); vi.mock('@tanstack/react-router', () => ({ diff --git a/packages/components/tests/workspace-writer.test.ts b/packages/components/tests/workspace-writer.test.ts index f8f360283..b43ce0a3c 100644 --- a/packages/components/tests/workspace-writer.test.ts +++ b/packages/components/tests/workspace-writer.test.ts @@ -366,3 +366,23 @@ describe('createDirectWorkspaceWriter', () => { ).rejects.toThrow('store unavailable'); }); }); + +it('prepares queue operations without publishing and replays one stable queue item', async () => { + const { createConversationSession } = await import('../src/lib/conversation-view'); + const doc = new LoroDoc(); + const session = createConversationSession(doc, { sessionId: 'queue-session' as never }); + const writer = createDirectWorkspaceWriter({ + acquireSessionStore: async () => ({ doc }), + releaseSessionStoreRef: () => {}, + } as never); + try { + const update = await writer.prepareSessionMessage('queue-session', { + userTurnId: 'fixed-turn', task: 'queued text', userId: 'account', + timestamp: '2026-01-01T00:00:00Z', isEditing: false, + }); + expect(session.mirror.getState().mq ?? []).toEqual([]); + await session.sessionData.commands.applyPreparedTurn(update); + await session.sessionData.commands.applyPreparedTurn(update); + expect(session.mirror.getState().mq?.map((item) => item.userTurnId)).toEqual(['fixed-turn']); + } finally { session.dispose(); doc.free(); } +}); diff --git a/packages/shared/src/electron-ipc-channels.ts b/packages/shared/src/electron-ipc-channels.ts index f91141ce9..b045aeb6f 100644 --- a/packages/shared/src/electron-ipc-channels.ts +++ b/packages/shared/src/electron-ipc-channels.ts @@ -26,6 +26,7 @@ export type IpcPushMap = { 'updater.state': ElectronUpdaterState; 'publicBrowser.state': ElectronPublicBrowserState; 'sessionControl.response': ElectronLocalSessionControlResponseEvent; + 'app.sendLifecycle': { requestId: string; phase: 'check' | 'commit'; reason: 'quit' | 'reload' | 'close' }; 'app.deepLink': string; 'app.menuAction': string; 'app.fullscreen': boolean; @@ -54,6 +55,7 @@ export const IPC_PUSH_CHANNELS = { updaterState: 'updater.state', publicBrowserState: 'publicBrowser.state', sessionControlResponse: 'sessionControl.response', + appSendLifecycle: 'app.sendLifecycle', appDeepLink: 'app.deepLink', appMenuAction: 'app.menuAction', appFullscreen: 'app.fullscreen', diff --git a/packages/shared/src/history-writer.ts b/packages/shared/src/history-writer.ts index 991be301d..e51f5e9d2 100644 --- a/packages/shared/src/history-writer.ts +++ b/packages/shared/src/history-writer.ts @@ -322,6 +322,10 @@ export interface HistoryWriter { updater: (history: SessionHistoryInput[]) => SessionHistoryInput[] ): () => void; append(entry: SessionHistory): void; + /** Validate and author on a fork without publishing; persist these bytes before import. */ + prepareAppend(entry: SessionHistory): Uint8Array; + /** Replay previously persisted prepared operations; importing twice is idempotent. */ + applyPrepared(update: Uint8Array): void; replace(turnId: string, entry: SessionHistory): boolean; /** * Stage a single-turn replacement without writing. Validates only changed @@ -522,6 +526,23 @@ export function createHistoryWriter(doc: LoroDoc, readHistory?: () => readonly S consumed = true; }; }, + prepareAppend(entry) { + const from = doc.version(); + const fork = doc.fork(); + try { + createHistoryWriter(fork).append(entry); + return fork.export({ mode: 'update', from }); + } finally { + fork.free(); + from.free(); + } + }, + applyPrepared(update) { + const imported = doc.import(update); + if (imported.pending && imported.pending.size > 0) { + throw new Error('Prepared history update is missing its original replica dependencies'); + } + }, append(entry) { const value = cleanNew(HistoryEntryWriteSchema, entry); populateContainer( diff --git a/packages/shared/src/session-data/AGENTS.md b/packages/shared/src/session-data/AGENTS.md index 5aa01dbc3..6c106a3d7 100644 --- a/packages/shared/src/session-data/AGENTS.md +++ b/packages/shared/src/session-data/AGENTS.md @@ -41,3 +41,7 @@ with a commit-time guard against regressing an advanced execution status. - Test the real Loro reader and writer. Delayed reads use small injected Promise gates; there is no test-only implementation of the complete command API. + +- Durable submission preparation stays within HistoryWriter: prepare on a fork, + persist the exact operations and their baseline before publishing, then replay + those operations without another append. Missing dependencies are not acceptance. diff --git a/packages/shared/src/session-data/loro.ts b/packages/shared/src/session-data/loro.ts index dbcdd15af..b24c7e5d6 100644 --- a/packages/shared/src/session-data/loro.ts +++ b/packages/shared/src/session-data/loro.ts @@ -372,6 +372,12 @@ export function createLoroSessionData(options: LoroSessionDataOptions) { else writer.update(apply); return { matched, proposal }; }, + async prepareAppendTurn(turn) { + return writer.prepareAppend(turn as unknown as SessionHistory); + }, + async applyPreparedTurn(update) { + writer.applyPrepared(update); + }, async appendTurn(turn) { writer.append(turn as unknown as SessionHistory); }, diff --git a/packages/shared/src/session-data/types.ts b/packages/shared/src/session-data/types.ts index 014dc14e7..e3f03bb5e 100644 --- a/packages/shared/src/session-data/types.ts +++ b/packages/shared/src/session-data/types.ts @@ -176,6 +176,9 @@ export interface SessionHistoryCommands { applyHistoryAction(action: HistoryAction): Promise; /** Append a new turn. Rejects invalid input before touching storage. */ appendTurn(turn: SessionTurn): Promise; + /** Prepare validated operations without publishing; the caller persists before replay. */ + prepareAppendTurn(turn: SessionTurn): Promise; + applyPreparedTurn(update: Uint8Array): Promise; /** Replace an existing turn by business id. */ replaceTurn(turnId: string, turn: SessionTurn): Promise; /** diff --git a/packages/shared/tests/history-writer.test.ts b/packages/shared/tests/history-writer.test.ts index d720e6c89..c0841955b 100644 --- a/packages/shared/tests/history-writer.test.ts +++ b/packages/shared/tests/history-writer.test.ts @@ -966,3 +966,50 @@ describe('single history writer', () => { current.dispose(); }); }); + + +describe('prepared history operation recovery', () => { + it('does not publish preparation and replays the same operation only once across replicas', () => { + const original = new Loro(); + const writer = createHistoryWriter(original); + writer.append(entry('existing')); + const persistedBaseline = original.export({ mode: 'snapshot' }); + const prepared = writer.prepareAppend(entry('fixed-id')); + expect(writer.readStored().map((turn) => turn.id)).toEqual(['existing']); + writer.applyPrepared(prepared); + writer.applyPrepared(prepared); + expect(writer.readStored().map((turn) => turn.id)).toEqual(['existing', 'fixed-id']); + + const recovered = new Loro(); + recovered.import(persistedBaseline); + const recoveredWriter = createHistoryWriter(recovered); + recoveredWriter.applyPrepared(prepared); + recoveredWriter.applyPrepared(prepared); + original.import(recovered.export({ mode: 'update' })); + recovered.import(original.export({ mode: 'update' })); + expect(recoveredWriter.readStored()).toEqual(writer.readStored()); + expect(writer.readStored().map((turn) => turn.id)).toEqual(['existing', 'fixed-id']); + }); + + it('refuses a missing baseline without claiming local acceptance', () => { + const original = new Loro(); + const writer = createHistoryWriter(original); + writer.append(entry('dependency')); + const prepared = writer.prepareAppend(entry('fixed-id')); + const recovered = new Loro(); + const recoveredWriter = createHistoryWriter(recovered); + expect(() => recoveredWriter.applyPrepared(prepared)).toThrow(/dependencies/); + recovered.import(original.export({ mode: 'snapshot' })); + recoveredWriter.applyPrepared(prepared); + expect(recoveredWriter.readStored().map((turn) => turn.id)).toEqual(['dependency', 'fixed-id']); + }); + + it('preserves the source when validation fails', () => { + const original = new Loro(); + const writer = createHistoryWriter(original); + writer.append(entry('existing')); + const before = original.toJSON(); + expect(() => writer.prepareAppend({ ...entry('invalid'), items: [{ type: 'text', text: 123 }] } as unknown as SessionHistory)).toThrow(); + expect(original.toJSON()).toEqual(before); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a6871847..e15b36f2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1300,6 +1300,9 @@ importers: '@vitejs/plugin-react': specifier: 'catalog:' version: 5.2.0(vite@8.2.2(@types/node@24.10.12)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + fake-indexeddb: + specifier: 6.2.5 + version: 6.2.5 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -8622,6 +8625,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -21618,6 +21625,8 @@ snapshots: transitivePeerDependencies: - supports-color + fake-indexeddb@6.2.5: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 diff --git a/specs/session-files.md b/specs/session-files.md index 4805158d6..4120da7c7 100644 --- a/specs/session-files.md +++ b/specs/session-files.md @@ -145,6 +145,8 @@ Takeover assigns stable submission/turn identity, preserving the existing `userT A fixed ID alone cannot prevent duplicate LoroList append. Implementation must validate cross-window exclusion, writer reconciliation, queue promotion, and reconnect. Sending twice with the same ID is not itself deduplication. The guarantee is that one local submission does not manufacture duplicate messages, not a new distributed exactly-once Agent execution mechanism. +Recovery uses the existing HistoryWriter to prepare exact CRDT operations on a temporary fork. Persist the original baseline first, save the operation bytes in a strict IndexedDB transaction, then import into the live document and flush. Replaying the same operations is idempotent; never re-append after an uncertain receipt. Imported operations require explicit repo synchronization because the current Streams adapter only subscribes to local edits. A successful target transport sync is a durable handoff, not proof that the Agent executed the message. Submission and delivery use separate per-session Web Locks; delivery checks earlier unfinished turns before dispatch. Recovery records are account/workspace scoped and windows exchange invalidations only. + Distinguish three evidence levels: 1. **Writer acceptance:** local CRDT mutation, without proof of disk persistence or Daemon receipt. diff --git a/specs/session-files.zh.md b/specs/session-files.zh.md index 997df3815..079818cc1 100644 --- a/specs/session-files.zh.md +++ b/specs/session-files.zh.md @@ -145,6 +145,8 @@ guide 冻结所指向的 assistant turn。准备完成前该 turn 已结束且 固定 ID 本身不能阻止 LoroList 重复 append。实现必须验证同记录多窗口互斥、writer 核对、队列提升及重连行为,不能把“请求发了两次但 ID 一样”当成成功去重。保障目标是一次本机提交不制造重复消息,不宣称提供新的分布式 exactly-once Agent 执行机制。 +恢复通过现有 HistoryWriter 在临时 fork 上准备精确的 CRDT 操作。先持久化原始基线,再用严格 IndexedDB 事务保存操作字节,最后导入实时文档并 flush。重放同一操作不会重复 append;回执未知时禁止重新 append。当前 Streams 适配器只监听本地编辑,因此导入操作后必须显式执行 repo 同步。目标 transport 同步成功只代表持久交接,不代表 Agent 已执行。提交和投递分别使用会话级 Web Lock;投递前检查更早的未完成消息。恢复记录按账号和 workspace 隔离,窗口之间只广播失效通知。 + 区分三层证据: 1. **writer 接受**:本地 CRDT 已变更,不代表数据已写盘,更不代表 Daemon 收到。 From 60aab7088c87088aadeed86e29912200b4f7e5b1 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Tue, 15 Sep 2026 00:49:57 +0800 Subject: [PATCH 2/6] fix: retain the operation preparing replica during send recovery Model: gpt-6 --- .../architecture/2026-09-14-deferred-attachment-send.md | 2 ++ .../2026-09-14-deferred-attachment-send.zh.md | 2 ++ apps/electron/src/AGENTS.md | 2 -- packages/components/src/lib/session-send-journal.ts | 9 ++++++++- .../src/providers/workspace-session-send-journal.ts | 1 + packages/components/tests/session-send-journal.test.ts | 8 ++++++++ 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md index 4457d0763..c55631348 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md @@ -111,3 +111,5 @@ Layer 2 PR: [#707](https://github.com/LodyAI/Lody/pull/707), based on #705. Layer 3 is in progress. To close the appended-history/lost-local-receipt window, the same HistoryWriter abstraction prepares operations on a temporary fork, persists the original replica name and exact operation bytes, and only then imports them into the live document. Restart replays the same operations instead of appending again. Flush the original baseline before publishing prepared operations; another window first loads that baseline, retaining the record and stopping if unavailable. A fresh empty replica cannot prove non-submission. Real Loro tests cover replay across two replicas, missing dependencies, and validation refusal. The journal includes strict IndexedDB receipts, account/workspace isolation, cross-window locks and invalidations, a recovery panel, and renderer exit checks before CLI shutdown. Imported prepared operations are explicitly synchronized through the existing target transport; a transport receipt is not Agent execution. Logout and cache/reset preserve outstanding recovery records. Expired authentication still fences access immediately. Transfer timing remains unchanged until layer 4. Packaged desktop/mobile acceptance remains outstanding. Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. + +Cross-window takeover records the replica that actually prepared the operations. The admitting window is not necessarily the source baseline owner. A deterministic journal test covers this recovery boundary. diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md index 24e6a3149..76907cafa 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md @@ -108,3 +108,5 @@ public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并 第三层正在实现。为关闭“已追加历史但磁盘确认丢失”的窗口,在同一个 HistoryWriter 抽象内先在临时 fork 准备操作,保存原副本名称及原始操作字节,然后才导入当前文档。重启重放相同操作,不重新 append。先 flush 原副本以保留操作依赖;跨窗口恢复先读取原副本,缺失时保留记录并停止,不以新窗口的空历史推断未发送。真实 Loro 测试已覆盖两副本重复重放、缺失依赖与校验失败;运行时、退出、UI 以及完整 IndexedDB 验证仍未接完,不能发布这一层。 Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. + +跨窗口接管时记录实际准备操作的副本;接管输入的窗口不一定拥有原操作基线。确定性 journal 测试覆盖此恢复边界。 diff --git a/apps/electron/src/AGENTS.md b/apps/electron/src/AGENTS.md index 52dca0a64..5b0b7fa64 100644 --- a/apps/electron/src/AGENTS.md +++ b/apps/electron/src/AGENTS.md @@ -115,5 +115,3 @@ native-dependency, and OSS-composition rules stay in `apps/electron/AGENTS.md`. - Text above the editor budget uses fixed bounded Range requests. Binary uses raw streams with backpressure/cancellation; raster header dimensions bound decode cost. The scheme never bypasses CSP, executes file content, or authorizes a remote RPC. - -- Product-window close/reload and app quit must check pending sends before draining renderer owners; stop the CLI only after every renderer drain settles. Keep cache/reset protection for recoverable messages. diff --git a/packages/components/src/lib/session-send-journal.ts b/packages/components/src/lib/session-send-journal.ts index f0eff4426..f8676282e 100644 --- a/packages/components/src/lib/session-send-journal.ts +++ b/packages/components/src/lib/session-send-journal.ts @@ -31,6 +31,7 @@ export type SessionSendJournalStorage = { export type SessionSendJournalPorts = { resources: SessionSendResources; + preparationReplica?: string; storage: SessionSendJournalStorage; observeExternal?(refresh: () => void): () => void; notifyExternal?(): void; @@ -96,7 +97,13 @@ export function createSessionSendJournal(ports: SessionSendJournalPorts) { if (record.stage === 'saved') { const update = await ports.prepare(record, signal); throwIfSendAborted(signal); - record = { ...record, update, stage: 'prepared', error: undefined }; + record = { + ...record, + update, + sourceReplica: ports.preparationReplica ?? record.sourceReplica, + stage: 'prepared', + error: undefined, + }; // No externally visible mutation may precede this storage receipt. await ports.storage.put(record); } diff --git a/packages/components/src/providers/workspace-session-send-journal.ts b/packages/components/src/providers/workspace-session-send-journal.ts index 526e329a0..c529c3abd 100644 --- a/packages/components/src/providers/workspace-session-send-journal.ts +++ b/packages/components/src/providers/workspace-session-send-journal.ts @@ -58,6 +58,7 @@ export function createWorkspaceSessionSendJournal(args: { let notify = () => {}; return createSessionSendJournal({ resources: runtime.sendResources, + preparationReplica: args.sourceReplica, storage, observeExternal: (refresh) => { if (typeof BroadcastChannel === 'undefined') return () => {}; diff --git a/packages/components/tests/session-send-journal.test.ts b/packages/components/tests/session-send-journal.test.ts index 49c2bb4ca..2df75e050 100644 --- a/packages/components/tests/session-send-journal.test.ts +++ b/packages/components/tests/session-send-journal.test.ts @@ -257,3 +257,11 @@ it('holds the session delivery lock until raw delivery settles', async () => { await Promise.all([first, second]); expect(delivered).toEqual(['first', 'second']); }); + +it('records the replica that actually prepared operations when another window takes over', async () => { + const f = fixture({ preparationReplica: 'executor-replica' }); + await f.journal.accept(record('cross-window')); + await f.journal.submit('session' as SessionId); + expect((await f.ports.storage.list())[0]?.sourceReplica).toBe('executor-replica'); + expect(f.writer.readStored().map((turn) => turn.id)).toEqual(['cross-window']); +}); From 95a68c7714ae5e572013f1a6c912dcacc967d24b Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 16 Sep 2026 15:11:52 +0800 Subject: [PATCH 3/6] fix: reuse queued submission for native steer Model: gpt-6 --- .../2026-09-14-deferred-attachment-send.md | 2 +- .../2026-09-14-deferred-attachment-send.zh.md | 2 +- .../sessions/message-queue/index.ts | 5 +++- .../message-queue/queued-message-steer.ts | 11 +++++++- .../sessions/session-chat-interface.tsx | 25 +++++++++++++++---- .../components/src/lib/session-submission.ts | 3 +++ .../tests/queued-message-steer.test.ts | 22 +++++++++++++++- .../tests/use-session-actions.test.ts | 11 ++++++-- .../workspace-join-request-route.test.tsx | 2 +- 9 files changed, 70 insertions(+), 13 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md index c55631348..dc449046e 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md @@ -110,6 +110,6 @@ Layer 2 PR: [#707](https://github.com/LodyAI/Lody/pull/707), based on #705. Layer 3 is in progress. To close the appended-history/lost-local-receipt window, the same HistoryWriter abstraction prepares operations on a temporary fork, persists the original replica name and exact operation bytes, and only then imports them into the live document. Restart replays the same operations instead of appending again. Flush the original baseline before publishing prepared operations; another window first loads that baseline, retaining the record and stopping if unavailable. A fresh empty replica cannot prove non-submission. Real Loro tests cover replay across two replicas, missing dependencies, and validation refusal. The journal includes strict IndexedDB receipts, account/workspace isolation, cross-window locks and invalidations, a recovery panel, and renderer exit checks before CLI shutdown. Imported prepared operations are explicitly synchronized through the existing target transport; a transport receipt is not Agent execution. Logout and cache/reset preserve outstanding recovery records. Expired authentication still fences access immediately. Transfer timing remains unchanged until layer 4. Packaged desktop/mobile acceptance remains outstanding. -Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. Native queue-steer reuses the existing queued journal entry instead of admitting a conflicting `pending_apply` duplicate; guide/delivery changes that existing record and retains its recovery identity. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. Cross-window takeover records the replica that actually prepared the operations. The admitting window is not necessarily the source baseline owner. A deterministic journal test covers this recovery boundary. diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md index 76907cafa..55cdfd596 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md @@ -107,6 +107,6 @@ public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并 第三层正在实现。为关闭“已追加历史但磁盘确认丢失”的窗口,在同一个 HistoryWriter 抽象内先在临时 fork 准备操作,保存原副本名称及原始操作字节,然后才导入当前文档。重启重放相同操作,不重新 append。先 flush 原副本以保留操作依赖;跨窗口恢复先读取原副本,缺失时保留记录并停止,不以新窗口的空历史推断未发送。真实 Loro 测试已覆盖两副本重复重放、缺失依赖与校验失败;运行时、退出、UI 以及完整 IndexedDB 验证仍未接完,不能发布这一层。 -Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. 原生 queue-steer 复用已有的 queued journal 条目,不会再添加冲突的 `pending_apply` 副本;guide/delivery 改写该已有条目并保留其恢复身份。`pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. 跨窗口接管时记录实际准备操作的副本;接管输入的窗口不一定拥有原操作基线。确定性 journal 测试覆盖此恢复边界。 diff --git a/packages/components/src/components/sessions/message-queue/index.ts b/packages/components/src/components/sessions/message-queue/index.ts index ae0123174..d5259b696 100644 --- a/packages/components/src/components/sessions/message-queue/index.ts +++ b/packages/components/src/components/sessions/message-queue/index.ts @@ -2,7 +2,10 @@ export { MessageQueueDisplay } from './message-queue-display'; export type { MessageQueueDisplayProps } from './message-queue-display'; export { MessageQueueRow } from './message-queue-row'; export type { MessageQueueRowProps } from './message-queue-row'; -export { shouldRequestNativeQueueSteer } from './queued-message-steer'; +export { + resolveQueuedUserHistoryEntry, + shouldRequestNativeQueueSteer, +} from './queued-message-steer'; export { QueuedImagePreview } from './queued-image-preview'; export type { QueuedImageBlock } from './queued-image-preview'; export { diff --git a/packages/components/src/components/sessions/message-queue/queued-message-steer.ts b/packages/components/src/components/sessions/message-queue/queued-message-steer.ts index 3eaafed0b..8f0a0013a 100644 --- a/packages/components/src/components/sessions/message-queue/queued-message-steer.ts +++ b/packages/components/src/components/sessions/message-queue/queued-message-steer.ts @@ -1,4 +1,4 @@ -import type { AcpCapabilityAuthority, AcpCapabilityCacheEntry } from '@lody/shared'; +import type { AcpCapabilityAuthority, AcpCapabilityCacheEntry, SessionHistory } from '@lody/shared'; export function shouldRequestNativeQueueSteer( authority: AcpCapabilityAuthority, @@ -6,3 +6,12 @@ export function shouldRequestNativeQueueSteer( ): boolean { return authority === 'authoritative' && capability?.acknowledgedSteer === true; } + +/** Reuse queue admission when native steer promotes that same user turn. */ +export async function resolveQueuedUserHistoryEntry( + journal: { read(id: string): Promise<{ entry: SessionHistory } | undefined> } | null | undefined, + userTurnId: string, + create: () => Promise +): Promise { + return (await journal?.read(userTurnId))?.entry ?? (await create()); +} diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 8ddc3c985..80a63e4d9 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -68,7 +68,11 @@ import { type SessionTurnAgentRoleSelection, } from './session-chat-input-area'; import { useSessionMcpSelection } from '@/hooks/use-session-mcp-selection'; -import { MessageQueueDisplay, shouldRequestNativeQueueSteer } from './message-queue'; +import { + MessageQueueDisplay, + resolveQueuedUserHistoryEntry, + shouldRequestNativeQueueSteer, +} from './message-queue'; import { useTranslation } from 'react-i18next'; import { useRouter } from '@tanstack/react-router'; import { toast } from 'sonner'; @@ -5153,10 +5157,20 @@ export const SessionChatInterface = memo( throw new Error('Queued message is empty'); } const queuedUserTurnId = item.userTurnId?.trim() || `queued-${item.$cid}`; - const { entry: historyEntry } = await addSessionHistory({ - ...pendingHistoryEntry, - id: queuedUserTurnId, - }); + // Queue admission already owns this turn ID. Promoting it to a guide + // must reuse that durable record: admitting a second, pending_apply + // version changes the persisted identity and is correctly rejected. + const historyEntry = await resolveQueuedUserHistoryEntry( + runtime?.sendJournal, + queuedUserTurnId, + async () => + ( + await addSessionHistory({ + ...pendingHistoryEntry, + id: queuedUserTurnId, + }) + ).entry + ); await removeMessageQueueItem(item.$cid); trackMessageSend(historyEntry.id); touchSessionActivity(session.id).catch((error: unknown) => { @@ -5190,6 +5204,7 @@ export const SessionChatInterface = memo( guideHistoryEntry, isExternalHistoryRefreshing, removeMessageQueueItem, + runtime, session.id, t, touchSessionActivity, diff --git a/packages/components/src/lib/session-submission.ts b/packages/components/src/lib/session-submission.ts index 4c07efbef..d20946034 100644 --- a/packages/components/src/lib/session-submission.ts +++ b/packages/components/src/lib/session-submission.ts @@ -488,6 +488,9 @@ export function createSessionSubmission(ports: SessionSubmissionPorts) { ); return false; } + if (response?.recoveryOwned && response.disposition === 'no-active-turn') { + return false; + } log( 'session steer not applied for %s/%s: %s', sessionId, diff --git a/packages/components/tests/queued-message-steer.test.ts b/packages/components/tests/queued-message-steer.test.ts index 18c3bcd1b..4aaddcecf 100644 --- a/packages/components/tests/queued-message-steer.test.ts +++ b/packages/components/tests/queued-message-steer.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { shouldRequestNativeQueueSteer } from '../src/components/sessions/message-queue/queued-message-steer'; +import type { SessionHistory } from '@lody/shared'; +import { + resolveQueuedUserHistoryEntry, + shouldRequestNativeQueueSteer, +} from '../src/components/sessions/message-queue/queued-message-steer'; describe('shouldRequestNativeQueueSteer', () => { it.each([ @@ -11,4 +15,20 @@ describe('shouldRequestNativeQueueSteer', () => { ] as const)('routes %s capability %o to native steer: %s', (authority, capability, expected) => { expect(shouldRequestNativeQueueSteer(authority, capability)).toBe(expected); }); + + it('reuses queue admission instead of admitting a conflicting pending_apply turn', async () => { + const entry = { + id: 'queued-turn', + role: 'user', + timestamp: '2026-09-16T00:00:00Z', + } as SessionHistory; + const replacement = { + ...entry, + status: 'pending_apply', + } as SessionHistory; + + await expect( + resolveQueuedUserHistoryEntry({ read: async () => ({ entry }) }, 'queued-turn', async () => replacement) + ).resolves.toBe(entry); + }); }); diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index c45c0cb04..59a246e10 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -1107,8 +1107,15 @@ describe('useSessionActions', () => { const result = actions.requestSessionSteer(sessionId, 'assistant:user-1', userTurnId, { machineId, }); - if (recoveryOwned && disposition === 'promotion-failed') { - await expect(result).rejects.toThrow('Injected activation write failure'); + if ( + (recoveryOwned && disposition === 'promotion-failed') || + disposition === 'delivery-unknown' + ) { + await expect(result).rejects.toThrow( + disposition === 'delivery-unknown' + ? 'Guide outcome is uncertain' + : 'Injected activation write failure' + ); } else { await expect(result).resolves.toBe(disposition === 'applied'); } diff --git a/packages/components/tests/workspace-join-request-route.test.tsx b/packages/components/tests/workspace-join-request-route.test.tsx index ed0a0c1ef..25a650859 100644 --- a/packages/components/tests/workspace-join-request-route.test.tsx +++ b/packages/components/tests/workspace-join-request-route.test.tsx @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ user: { email: string }; }, authClient: { id: 'auth-client' }, - signOutWithoutRedirect: vi.fn().mockResolvedValue(true), + signOutWithoutRedirect: vi.fn().mockResolvedValue({ ok: true }), })); vi.mock('@tanstack/react-router', () => ({ From b1a73c0bb171dc633c53573f71af99f9689faf6c Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 16 Sep 2026 18:32:50 +0800 Subject: [PATCH 4/6] fix: preserve queue steer history and recovery exits Model: gpt-6 --- .../2026-09-14-deferred-attachment-send.md | 2 +- .../2026-09-14-deferred-attachment-send.zh.md | 2 +- locales/en.json | 5 +- locales/zh_CN.json | 5 +- .../components/chat/session-send-recovery.tsx | 40 ++++++++++++++- .../sessions/message-queue/index.ts | 1 - .../message-queue/queued-message-steer.ts | 11 +--- .../sessions/session-chat-interface.tsx | 27 +++++----- .../src/lib/session-send-journal.ts | 51 +++++++++++++++++++ .../tests/queued-message-steer.test.ts | 22 +------- .../tests/session-send-journal.test.ts | 43 ++++++++++++++++ 11 files changed, 158 insertions(+), 51 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md index dc449046e..4d401077f 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md @@ -110,6 +110,6 @@ Layer 2 PR: [#707](https://github.com/LodyAI/Lody/pull/707), based on #705. Layer 3 is in progress. To close the appended-history/lost-local-receipt window, the same HistoryWriter abstraction prepares operations on a temporary fork, persists the original replica name and exact operation bytes, and only then imports them into the live document. Restart replays the same operations instead of appending again. Flush the original baseline before publishing prepared operations; another window first loads that baseline, retaining the record and stopping if unavailable. A fresh empty replica cannot prove non-submission. Real Loro tests cover replay across two replicas, missing dependencies, and validation refusal. The journal includes strict IndexedDB receipts, account/workspace isolation, cross-window locks and invalidations, a recovery panel, and renderer exit checks before CLI shutdown. Imported prepared operations are explicitly synchronized through the existing target transport; a transport receipt is not Agent execution. Logout and cache/reset preserve outstanding recovery records. Expired authentication still fences access immediately. Transfer timing remains unchanged until layer 4. Packaged desktop/mobile acceptance remains outstanding. -Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. Native queue-steer reuses the existing queued journal entry instead of admitting a conflicting `pending_apply` duplicate; guide/delivery changes that existing record and retains its recovery identity. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. Native queue-steer keeps the queued journal identity but first promotes its delivered queue operation back to saved history work; the history turn is durably prepared and committed before the queue row can be removed or guide delivery begins. A committed record with an unknown result may be explicitly discarded only after disclosure that it may already have sent; destructive logout/cache-clear offers the same disclosed exit. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. Cross-window takeover records the replica that actually prepared the operations. The admitting window is not necessarily the source baseline owner. A deterministic journal test covers this recovery boundary. diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md index 55cdfd596..c7039ec8b 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md @@ -107,6 +107,6 @@ public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并 第三层正在实现。为关闭“已追加历史但磁盘确认丢失”的窗口,在同一个 HistoryWriter 抽象内先在临时 fork 准备操作,保存原副本名称及原始操作字节,然后才导入当前文档。重启重放相同操作,不重新 append。先 flush 原副本以保留操作依赖;跨窗口恢复先读取原副本,缺失时保留记录并停止,不以新窗口的空历史推断未发送。真实 Loro 测试已覆盖两副本重复重放、缺失依赖与校验失败;运行时、退出、UI 以及完整 IndexedDB 验证仍未接完,不能发布这一层。 -Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. 原生 queue-steer 复用已有的 queued journal 条目,不会再添加冲突的 `pending_apply` 副本;guide/delivery 改写该已有条目并保留其恢复身份。`pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. 原生 queue-steer 保留 queued journal 的身份,但会先将已经投递的 queue 操作提升回已保存的 history 工作;history turn 已持久准备并提交后,才能删除 queue 行或开始 guide 投递。结果未知的 committed 记录只能在披露其可能已经发送后显式丢弃;退出登录/清缓存提供相同的披露后出口。`pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. 跨窗口接管时记录实际准备操作的副本;接管输入的窗口不一定拥有原操作基线。确定性 journal 测试覆盖此恢复边界。 diff --git a/locales/en.json b/locales/en.json index 98a219d89..8e850e67f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -4159,10 +4159,13 @@ "sessions.retryPendingSend": "Continue sending", "sessions.cancelPendingSend": "Cancel send", "sessions.pendingSendExitTitle": "Some messages are still pending", - "sessions.pendingSendDestructiveExit": "Finish or cancel pending messages before signing out or clearing caches. Some messages may already have been sent; keep their recovery data until the result is confirmed.", + "sessions.pendingSendDestructiveExit": "Some messages may already have been sent. Continuing will discard their recovery data and you will not be able to confirm their result after signing out or clearing caches.", "sessions.pendingSendRetainedExit": "Pending messages will stay saved on this device. Some may already have been sent. Return to this workspace to confirm the result or continue sending.", "sessions.stayWithPendingSends": "Stay", "sessions.leaveWithPendingSends": "Leave and keep messages", + "sessions.discardPendingSendsAndContinue": "Discard recovery and continue", + "sessions.discardPendingSend": "Discard recovery", + "sessions.discardPendingSendDescription": "This message may already have been sent. Discarding recovery data cannot confirm its result.", "sessions.pendingSendExitUnavailable": "Could not confirm pending messages. Keep Lody open and try again.", "sessions.pendingSendQuit": "Quit and keep messages", "sessions.pendingSendReload": "Reload and keep messages", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 06321fc32..d0a4534f2 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -4159,10 +4159,13 @@ "sessions.retryPendingSend": "继续发送", "sessions.cancelPendingSend": "取消发送", "sessions.pendingSendExitTitle": "还有消息尚未完成发送", - "sessions.pendingSendDestructiveExit": "请先完成或取消待发送消息,再退出登录或清理缓存。部分消息可能已经发送,需要保留恢复数据来确认结果。", + "sessions.pendingSendDestructiveExit": "部分消息可能已经发送。继续将丢弃恢复数据;退出登录或清理缓存后将无法确认其结果。", "sessions.pendingSendRetainedExit": "待发送消息会保留在本机。部分消息可能已经发送。返回此工作区后可以确认结果或继续发送。", "sessions.stayWithPendingSends": "留在此处", "sessions.leaveWithPendingSends": "离开并保留消息", + "sessions.discardPendingSendsAndContinue": "丢弃恢复数据并继续", + "sessions.discardPendingSend": "丢弃恢复数据", + "sessions.discardPendingSendDescription": "这条消息可能已经发送。丢弃恢复数据后无法确认其结果。", "sessions.pendingSendExitUnavailable": "无法确认待发送消息的状态。请保持 Lody 打开并重试。", "sessions.pendingSendQuit": "退出并保留消息", "sessions.pendingSendReload": "重新加载并保留消息", diff --git a/packages/components/src/components/chat/session-send-recovery.tsx b/packages/components/src/components/chat/session-send-recovery.tsx index 70fd7c2a2..ba6885da3 100644 --- a/packages/components/src/components/chat/session-send-recovery.tsx +++ b/packages/components/src/components/chat/session-send-recovery.tsx @@ -172,6 +172,21 @@ export function SessionSendRecovery({ runtime }: { runtime: WorkspaceRuntime | n setBusy(null); } }; + const discard = async (record: SessionSendRecord) => { + if (!journal) return; + setBusy(record.id); + try { + await journal.discard(record.id); + void journal + .retry(record.sessionId) + .catch((failure: unknown) => console.warn('Following message remains pending', failure)); + setError(null); + } catch (failure) { + setError(failure instanceof Error ? failure.message : t('sessions.sendRecoveryUnavailable')); + } finally { + setBusy(null); + } + }; const destructiveExit = exitRequest?.reason === 'logout' || exitRequest?.reason === 'cache-clear'; return ( @@ -231,7 +246,24 @@ export function SessionSendRecovery({ runtime }: { runtime: WorkspaceRuntime | n {t('sessions.cancelPendingSend')} ) : null} + {record.stage === 'committed' ? ( + + ) : null} + {record.stage === 'committed' ? ( +

+ {t('sessions.discardPendingSendDescription')} +

+ ) : null} ))} @@ -263,11 +295,15 @@ export function SessionSendRecovery({ runtime }: { runtime: WorkspaceRuntime | n finishExit(false)}> {t('sessions.stayWithPendingSends')} - {!destructiveExit ? ( + {destructiveExit ? ( + + ) : ( - ) : null} + )} diff --git a/packages/components/src/components/sessions/message-queue/index.ts b/packages/components/src/components/sessions/message-queue/index.ts index d5259b696..e2db929f2 100644 --- a/packages/components/src/components/sessions/message-queue/index.ts +++ b/packages/components/src/components/sessions/message-queue/index.ts @@ -3,7 +3,6 @@ export type { MessageQueueDisplayProps } from './message-queue-display'; export { MessageQueueRow } from './message-queue-row'; export type { MessageQueueRowProps } from './message-queue-row'; export { - resolveQueuedUserHistoryEntry, shouldRequestNativeQueueSteer, } from './queued-message-steer'; export { QueuedImagePreview } from './queued-image-preview'; diff --git a/packages/components/src/components/sessions/message-queue/queued-message-steer.ts b/packages/components/src/components/sessions/message-queue/queued-message-steer.ts index 8f0a0013a..3eaafed0b 100644 --- a/packages/components/src/components/sessions/message-queue/queued-message-steer.ts +++ b/packages/components/src/components/sessions/message-queue/queued-message-steer.ts @@ -1,4 +1,4 @@ -import type { AcpCapabilityAuthority, AcpCapabilityCacheEntry, SessionHistory } from '@lody/shared'; +import type { AcpCapabilityAuthority, AcpCapabilityCacheEntry } from '@lody/shared'; export function shouldRequestNativeQueueSteer( authority: AcpCapabilityAuthority, @@ -6,12 +6,3 @@ export function shouldRequestNativeQueueSteer( ): boolean { return authority === 'authoritative' && capability?.acknowledgedSteer === true; } - -/** Reuse queue admission when native steer promotes that same user turn. */ -export async function resolveQueuedUserHistoryEntry( - journal: { read(id: string): Promise<{ entry: SessionHistory } | undefined> } | null | undefined, - userTurnId: string, - create: () => Promise -): Promise { - return (await journal?.read(userTurnId))?.entry ?? (await create()); -} diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 80a63e4d9..067d489fc 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -70,7 +70,6 @@ import { import { useSessionMcpSelection } from '@/hooks/use-session-mcp-selection'; import { MessageQueueDisplay, - resolveQueuedUserHistoryEntry, shouldRequestNativeQueueSteer, } from './message-queue'; import { useTranslation } from 'react-i18next'; @@ -5157,20 +5156,22 @@ export const SessionChatInterface = memo( throw new Error('Queued message is empty'); } const queuedUserTurnId = item.userTurnId?.trim() || `queued-${item.$cid}`; - // Queue admission already owns this turn ID. Promoting it to a guide - // must reuse that durable record: admitting a second, pending_apply - // version changes the persisted identity and is correctly rejected. - const historyEntry = await resolveQueuedUserHistoryEntry( - runtime?.sendJournal, + // Queue admission owns this ID, but its delivered operation only + // inserted the queue row. Promote that record back to saved work so + // the journal appends the matching history turn before queue removal. + const promoted = await runtime?.sendJournal?.promoteQueuedTurn( queuedUserTurnId, - async () => - ( - await addSessionHistory({ - ...pendingHistoryEntry, - id: queuedUserTurnId, - }) - ).entry + { ...pendingHistoryEntry, id: queuedUserTurnId }, + { kind: 'guide', expectedTurnId: activeAssistantTurnId } ); + const historyEntry = + promoted?.entry ?? + ( + await addSessionHistory({ + ...pendingHistoryEntry, + id: queuedUserTurnId, + }) + ).entry; await removeMessageQueueItem(item.$cid); trackMessageSend(historyEntry.id); touchSessionActivity(session.id).catch((error: unknown) => { diff --git a/packages/components/src/lib/session-send-journal.ts b/packages/components/src/lib/session-send-journal.ts index f8676282e..00a5892b8 100644 --- a/packages/components/src/lib/session-send-journal.ts +++ b/packages/components/src/lib/session-send-journal.ts @@ -170,6 +170,41 @@ export function createSessionSendJournal(ports: SessionSendJournalPorts) { }, refresh, read: async (id: string) => (await ports.storage.list()).find((record) => record.id === id), + /** + * A native queue steer changes an already delivered queue operation into a + * normal history turn. Keep its durable identity, but prepare fresh history + * operations before the queue row may be removed. + */ + promoteQueuedTurn: async ( + id: string, + entry: SessionHistory, + delivery: Extract + ) => + ports.resources.run(async (signal) => { + const found = (await ports.storage.list()).find((record) => record.id === id); + if (!found) return undefined; + return ports.lock(`submit:${found.sessionId}`, signal, async () => { + const current = (await ports.storage.list()).find((record) => record.id === id); + if (!current) return undefined; + if (!current.queue) return current; + if (current.stage !== 'delivered') + throw new Error('Queued message must be delivered before it can be guided'); + if (current.guideOffer) + throw new Error('Guide outcome must be reconciled before promoting the queued message'); + const next: SessionSendRecord = { + ...current, + entry, + queue: undefined, + delivery, + stage: 'saved', + update: undefined, + error: undefined, + }; + await ports.storage.put(next); + await changed(); + return next; + }); + }), activate: async (id: string, delivery: SessionSendRecord['delivery']) => ports.resources.run(async (signal) => { const found = (await ports.storage.list()).find((record) => record.id === id); @@ -242,6 +277,22 @@ export function createSessionSendJournal(ports: SessionSendJournalPorts) { await changed(); }); }), + /** Discard a locally committed recovery obligation after explicit user disclosure. */ + discard: async (id: string) => + ports.resources.run(async (signal) => { + const found = (await ports.storage.list()).find((record) => record.id === id); + if (!found) return; + await ports.lock(`submit:${found.sessionId}`, signal, async () => { + await ports.lock(`delivery:${found.sessionId}`, signal, async () => { + const current = (await ports.storage.list()).find((record) => record.id === id); + if (!current) return; + if (current.stage !== 'committed') + throw new Error('Only a committed submission can be discarded'); + await ports.storage.remove(id); + await changed(); + }); + }); + }), close: async () => { closed = true; unobserve?.(); diff --git a/packages/components/tests/queued-message-steer.test.ts b/packages/components/tests/queued-message-steer.test.ts index 4aaddcecf..18c3bcd1b 100644 --- a/packages/components/tests/queued-message-steer.test.ts +++ b/packages/components/tests/queued-message-steer.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { SessionHistory } from '@lody/shared'; -import { - resolveQueuedUserHistoryEntry, - shouldRequestNativeQueueSteer, -} from '../src/components/sessions/message-queue/queued-message-steer'; +import { shouldRequestNativeQueueSteer } from '../src/components/sessions/message-queue/queued-message-steer'; describe('shouldRequestNativeQueueSteer', () => { it.each([ @@ -15,20 +11,4 @@ describe('shouldRequestNativeQueueSteer', () => { ] as const)('routes %s capability %o to native steer: %s', (authority, capability, expected) => { expect(shouldRequestNativeQueueSteer(authority, capability)).toBe(expected); }); - - it('reuses queue admission instead of admitting a conflicting pending_apply turn', async () => { - const entry = { - id: 'queued-turn', - role: 'user', - timestamp: '2026-09-16T00:00:00Z', - } as SessionHistory; - const replacement = { - ...entry, - status: 'pending_apply', - } as SessionHistory; - - await expect( - resolveQueuedUserHistoryEntry({ read: async () => ({ entry }) }, 'queued-turn', async () => replacement) - ).resolves.toBe(entry); - }); }); diff --git a/packages/components/tests/session-send-journal.test.ts b/packages/components/tests/session-send-journal.test.ts index 2df75e050..d252b7c5f 100644 --- a/packages/components/tests/session-send-journal.test.ts +++ b/packages/components/tests/session-send-journal.test.ts @@ -143,6 +143,49 @@ describe('persistent submission stages', () => { await expect(f.journal.cancel('fixed')).rejects.toThrow(/already be accepted/); expect(f.writer.readStored().map((turn) => turn.id)).toEqual(['fixed']); }); + + it('promotes a delivered queue record into a history turn before guide delivery', async () => { + const f = fixture(); + await f.journal.accept({ + ...record('queued-turn'), + delivery: { kind: 'queue' }, + queue: { $cid: 'queue-row' }, + }); + const queued = (await f.ports.storage.list())[0]!; + await f.ports.storage.put({ ...queued, stage: 'delivered' }); + const promoted = await f.journal.promoteQueuedTurn( + 'queued-turn', + { + ...record('queued-turn').entry, + status: 'pending_apply', + }, + { kind: 'guide', expectedTurnId: 'assistant-turn' } + ); + expect(promoted).toMatchObject({ + stage: 'saved', + queue: undefined, + delivery: { kind: 'guide', expectedTurnId: 'assistant-turn' }, + }); + + await f.journal.retry('session' as SessionId); + expect(f.writer.readStored().map((turn) => turn.id)).toEqual(['queued-turn']); + expect((await f.ports.storage.list())[0]).toMatchObject({ + stage: 'delivered', + delivery: { kind: 'guide', expectedTurnId: 'assistant-turn' }, + }); + }); + + it('lets an explicit discard remove an undeliverable committed recovery record', async () => { + const f = fixture({ + deliver: async () => { + throw new Error('Target unavailable'); + }, + }); + await f.journal.accept(record('stuck')); + await expect(f.journal.retry('session' as SessionId)).rejects.toThrow('Target unavailable'); + await f.journal.discard('stuck'); + expect(await f.ports.storage.list()).toEqual([]); + }); }); describe('IndexedDB recovery receipts', () => { From 36bf9c3bd59ccd6e750af39bd3cc73f8a15810ef Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 16 Sep 2026 18:43:58 +0800 Subject: [PATCH 5/6] fix: complete forced recovery exits Model: gpt-6 --- .../2026-09-14-deferred-attachment-send.md | 2 +- .../2026-09-14-deferred-attachment-send.zh.md | 2 +- locales/en.json | 1 + locales/zh_CN.json | 1 + .../components/chat/session-send-recovery.tsx | 10 ++- .../components/error-boundary-fallback.tsx | 2 +- .../components/src/lib/clear-local-cache.ts | 76 +++++++++++++------ .../src/lib/session-send-journal.ts | 4 +- .../tests/session-send-journal.test.ts | 15 ++++ 9 files changed, 83 insertions(+), 30 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md index 4d401077f..46bd76cfa 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.md @@ -110,6 +110,6 @@ Layer 2 PR: [#707](https://github.com/LodyAI/Lody/pull/707), based on #705. Layer 3 is in progress. To close the appended-history/lost-local-receipt window, the same HistoryWriter abstraction prepares operations on a temporary fork, persists the original replica name and exact operation bytes, and only then imports them into the live document. Restart replays the same operations instead of appending again. Flush the original baseline before publishing prepared operations; another window first loads that baseline, retaining the record and stopping if unavailable. A fresh empty replica cannot prove non-submission. Real Loro tests cover replay across two replicas, missing dependencies, and validation refusal. The journal includes strict IndexedDB receipts, account/workspace isolation, cross-window locks and invalidations, a recovery panel, and renderer exit checks before CLI shutdown. Imported prepared operations are explicitly synchronized through the existing target transport; a transport receipt is not Agent execution. Logout and cache/reset preserve outstanding recovery records. Expired authentication still fences access immediately. Transfer timing remains unchanged until layer 4. Packaged desktop/mobile acceptance remains outstanding. -Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. Native queue-steer keeps the queued journal identity but first promotes its delivered queue operation back to saved history work; the history turn is durably prepared and committed before the queue row can be removed or guide delivery begins. A committed record with an unknown result may be explicitly discarded only after disclosure that it may already have sent; destructive logout/cache-clear offers the same disclosed exit. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. Native queue-steer keeps the queued journal identity but first promotes its delivered queue operation back to saved history work; the history turn is durably prepared and committed before the queue row can be removed or guide delivery begins. A prepared or committed record may be explicitly discarded only after disclosure; destructive logout/cache-clear writes a forced-clear marker that actually deletes the recovery database on boot. A non-forced clear blocked by recovery stays pending without preventing runtime initialization. `pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. Cross-window takeover records the replica that actually prepared the operations. The admitting window is not necessarily the source baseline owner. A deterministic journal test covers this recovery boundary. diff --git a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md index c7039ec8b..609a083b7 100644 --- a/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-14-deferred-attachment-send.zh.md @@ -107,6 +107,6 @@ public-boundary 检查及文档检查分别通过。已运行 `pnpm format` 并 第三层正在实现。为关闭“已追加历史但磁盘确认丢失”的窗口,在同一个 HistoryWriter 抽象内先在临时 fork 准备操作,保存原副本名称及原始操作字节,然后才导入当前文档。重启重放相同操作,不重新 append。先 flush 原副本以保留操作依赖;跨窗口恢复先读取原副本,缺失时保留记录并停止,不以新窗口的空历史推断未发送。真实 Loro 测试已覆盖两副本重复重放、缺失依赖与校验失败;运行时、退出、UI 以及完整 IndexedDB 验证仍未接完,不能发布这一层。 -Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. 原生 queue-steer 保留 queued journal 的身份,但会先将已经投递的 queue 操作提升回已保存的 history 工作;history turn 已持久准备并提交后,才能删除 queue 行或开始 guide 投递。结果未知的 committed 记录只能在披露其可能已经发送后显式丢弃;退出登录/清缓存提供相同的披露后出口。`pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. +Layer 3 validation: full `TMPDIR=/private/tmp NODE_ENV=test pnpm check` passes, including 479 component files / 3,670 tests. Queue preparation uses the existing WorkspaceWriter and retains queue format. 原生 queue-steer 保留 queued journal 的身份,但会先将已经投递的 queue 操作提升回已保存的 history 工作;history turn 已持久准备并提交后,才能删除 queue 行或开始 guide 投递。prepared 或 committed 记录只能在披露后显式丢弃;退出登录/清缓存会写入强制清理标记,并在下次启动时实际删除恢复数据库。被恢复记录阻挡的非强制清理会保留请求,但不会阻止 runtime 初始化。`pnpm format` and docs check completed; docs report zero errors. No packaged-device acceptance is claimed. 跨窗口接管时记录实际准备操作的副本;接管输入的窗口不一定拥有原操作基线。确定性 journal 测试覆盖此恢复边界。 diff --git a/locales/en.json b/locales/en.json index 8e850e67f..2db5fb625 100644 --- a/locales/en.json +++ b/locales/en.json @@ -4166,6 +4166,7 @@ "sessions.discardPendingSendsAndContinue": "Discard recovery and continue", "sessions.discardPendingSend": "Discard recovery", "sessions.discardPendingSendDescription": "This message may already have been sent. Discarding recovery data cannot confirm its result.", + "sessions.discardPreparedSendDescription": "This prepared message will no longer be recovered or delivered.", "sessions.pendingSendExitUnavailable": "Could not confirm pending messages. Keep Lody open and try again.", "sessions.pendingSendQuit": "Quit and keep messages", "sessions.pendingSendReload": "Reload and keep messages", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index d0a4534f2..8114ef688 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -4166,6 +4166,7 @@ "sessions.discardPendingSendsAndContinue": "丢弃恢复数据并继续", "sessions.discardPendingSend": "丢弃恢复数据", "sessions.discardPendingSendDescription": "这条消息可能已经发送。丢弃恢复数据后无法确认其结果。", + "sessions.discardPreparedSendDescription": "这条已准备消息将不再恢复或投递。", "sessions.pendingSendExitUnavailable": "无法确认待发送消息的状态。请保持 Lody 打开并重试。", "sessions.pendingSendQuit": "退出并保留消息", "sessions.pendingSendReload": "重新加载并保留消息", diff --git a/packages/components/src/components/chat/session-send-recovery.tsx b/packages/components/src/components/chat/session-send-recovery.tsx index ba6885da3..1bde43ba3 100644 --- a/packages/components/src/components/chat/session-send-recovery.tsx +++ b/packages/components/src/components/chat/session-send-recovery.tsx @@ -246,7 +246,7 @@ export function SessionSendRecovery({ runtime }: { runtime: WorkspaceRuntime | n {t('sessions.cancelPendingSend')} ) : null} - {record.stage === 'committed' ? ( + {record.stage === 'prepared' || record.stage === 'committed' ? (