mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user