From fba8cb3f3d7cc6ba1fdd89d3a677e8838fdf237d Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 21 Sep 2026 11:23:51 -0400 Subject: [PATCH] fix(mobile): roll the journal mirror back when persistence fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `writeEntries` noted the mirror before the store took it, which is what keeps an `init` built in the same turn current — but it kept the note when the store refused. The page then received a journal the device never wrote and resumed operations nothing was holding. Restored on the error path, the same shape as the custom-keys save, and on both halves: the removal that empties the journal had the same gap as the write that fills it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...-structured-send-operation-journal.test.ts | 44 +++++++++++++++++++ ...obile-structured-send-operation-journal.ts | 25 ++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/mobile/src/session/mobile-structured-send-operation-journal.test.ts b/mobile/src/session/mobile-structured-send-operation-journal.test.ts index 65a2b61b8b9..cee58bded95 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.test.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.test.ts @@ -18,8 +18,11 @@ import { mobileStructuredSendOperationKey, resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import { readMirroredStorage } from '../storage/mirrored-storage-keys' const NOW = 1_900_000_000_000 +/** The key the journal persists under, which the hybrid shell mirrors into every `init`. */ +const JOURNAL_KEY = 'orca:mobileStructuredSendOperations:v1' const OPERATION_KEY = 'a'.repeat(64) const CALLER_IDENTITY = 'mobile-device-a' @@ -80,6 +83,47 @@ describe('mobile structured send operation journal', () => { expect(createAfterRemount).not.toHaveBeenCalled() }) + /** + * A mirror the page reads is not allowed to run ahead of the store (round 4, CodeRabbit). + * + * The hybrid shell builds `init` from the mirror synchronously, so the page is handed whatever + * was noted here. Noting the write before it is persisted is what keeps an `init` in the same + * turn current; keeping the note after the persist was refused publishes a journal that does + * not exist, and the page resumes operations the device never wrote down. + */ + it('rolls the mirror back when persisting an added entry fails', async () => { + await getOrCreateMobileStructuredSendOperation({ + operationKey: OPERATION_KEY, + createOperationId: () => operationIdAt(NOW, '8'), + now: NOW + }) + const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY] + asyncStorage.setItem.mockRejectedValueOnce(new Error('the store is full')) + await expect( + getOrCreateMobileStructuredSendOperation({ + operationKey: 'c'.repeat(64), + createOperationId: () => operationIdAt(NOW, '9'), + now: NOW + }) + ).rejects.toThrow('the store is full') + expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held) + }) + + it('rolls the mirror back when persisting the last clear fails', async () => { + const firstId = operationIdAt(NOW, 'a') + await getOrCreateMobileStructuredSendOperation({ + operationKey: OPERATION_KEY, + createOperationId: () => firstId, + now: NOW + }) + const held = readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY] + asyncStorage.removeItem.mockRejectedValueOnce(new Error('the store is full')) + await expect( + clearMobileStructuredSendOperation({ operationKey: OPERATION_KEY, operationId: firstId }) + ).rejects.toThrow('the store is full') + expect(readMirroredStorage([JOURNAL_KEY])[JOURNAL_KEY]).toBe(held) + }) + it('clears only the exact settled operation', async () => { const firstId = operationIdAt(NOW, '3') await getOrCreateMobileStructuredSendOperation({ diff --git a/mobile/src/session/mobile-structured-send-operation-journal.ts b/mobile/src/session/mobile-structured-send-operation-journal.ts index 2db4694e2fe..d8c1c2ad48b 100644 --- a/mobile/src/session/mobile-structured-send-operation-journal.ts +++ b/mobile/src/session/mobile-structured-send-operation-journal.ts @@ -1,6 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { z } from 'zod' -import { noteMirroredWrite } from '../storage/mirrored-storage-keys' +import { noteMirroredWrite, readMirroredStorage } from '../storage/mirrored-storage-keys' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, @@ -87,16 +87,21 @@ function parseJournal(raw: string | null): OperationJournal { } async function writeEntries(entries: OperationEntry[]): Promise { - if (entries.length === 0) { - // Noted before it is persisted: the hybrid shell hands this key to the page on every `init`, - // built synchronously, so a write that only reached the store would be one `init` behind. - noteMirroredWrite(STORAGE_KEY, null) - await AsyncStorage.removeItem(STORAGE_KEY) - return - } - const value = JSON.stringify({ v: 1, entries }) + const value = entries.length === 0 ? null : JSON.stringify({ v: 1, entries }) + // Noted before it is persisted: the hybrid shell hands this key to the page on every `init`, + // built synchronously, so a write that only reached the store would be one `init` behind. Put + // back when the store refuses it, because the other direction is worse — a page told about a + // journal the device never wrote resumes operations nothing is holding. + const held = readMirroredStorage([STORAGE_KEY])[STORAGE_KEY] ?? null noteMirroredWrite(STORAGE_KEY, value) - await AsyncStorage.setItem(STORAGE_KEY, value) + try { + await (value === null + ? AsyncStorage.removeItem(STORAGE_KEY) + : AsyncStorage.setItem(STORAGE_KEY, value)) + } catch (error) { + noteMirroredWrite(STORAGE_KEY, held) + throw error + } } async function serialize(action: () => Promise): Promise {