fix(mobile): roll the journal mirror back when persistence fails

`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
This commit is contained in:
Jinwoo-H
2026-09-21 11:23:51 -04:00
parent e89ab25d46
commit fba8cb3f3d
2 changed files with 59 additions and 10 deletions
@@ -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({
@@ -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<void> {
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<T>(action: () => Promise<T>): Promise<T> {