diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 642918a992c0..bc3b05b8d318 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -639,7 +639,7 @@ "protection": "partial", "owner": "agent-session-runtime", "layer": "runtime-unit", - "surfaces": ["structured chat journal replay", "structured chat recovery"], + "surfaces": ["structured chat journal replay", "structured chat damaged history"], "platforms": ["macos", "linux", "windows"], "providers": ["local", "remote-runtime"], "coveredPlatforms": ["macos"], @@ -648,15 +648,17 @@ "motivatingLinks": [ "https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts" ], - "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, repair precedence, read-only schema latching and cursor cleanup while retaining live items rather than all historical bodies.", - "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; preserve prefix and future-schema latching after a gap, malformed suffix repair precedence, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content and recovery.", + "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, a newer schema winning over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.", + "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; name a future-schema row read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.", "commands": [ + // As the 2026-09-11 evidence run ran it; the recovery test it names went with the read-time repair. "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts", - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-damage.test.ts" ], "testFiles": [ "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts", - "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + "src/main/native-chat/agent-session-journal/journal-damage.test.ts" ], "assertionRefs": [ { @@ -664,9 +666,18 @@ "assertions": [ "releases superseded revision bodies while reducing a long journal", "holds no read snapshot while reducing, so a checkpoint can pass mid-replay", - "keeps the prefix but latches read-only for a future row beyond a gap", - "keeps gap repair precedence when a later row is malformed", - "rejects an unanchored prefix before a later gap" + "reads past a gap to a future row, which names the newer row and no damage", + "names the first damage when a later row is malformed too", + "names a missing epoch row before a later gap" + ] + }, + { + "file": "src/main/native-chat/agent-session-journal/journal-damage.test.ts", + "assertions": [ + "fails to load and keeps every row: %s", + "reads past damage to a newer build's row, which fails the load as a newer Orca's instead", + "is refused when it is written, and the chat still loads with every other row", + "is founded afresh on open, with nothing deleted, and takes writes" ] } ], @@ -679,6 +690,15 @@ "result": "passed", "durationSeconds": 7.71, "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed." + }, + { + "date": "2026-10-02", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts", + "result": "passed", + "durationSeconds": 5.56, + "summary": "450 tests passed across 42 files, run as the vitest invocation the test script makes. Damage fails the load with every row kept; the retained-heap, newer-row-past-a-gap and cursor-cleanup assertions passed." } ], "runtimeBudget": { diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 3650528d4660..d3d6632b68e9 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -80,6 +80,7 @@ export function MobileNativeChatOverlay({ folded={folded} status={session.status} error={session.error} + readFailedFinally={session.readFailedFinally === true} agent={controller.nativeChatAgent} agentWorking={controller.nativeChatAgentWorking} canStop={controller.nativeChatCanStop} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 68e5c21df8a4..9d82f254ffce 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -145,4 +145,56 @@ describe('MobileNativeChatPermission', () => { expect(content.findAllByProps({ children: 'raw json that must not be shown' })).toHaveLength(0) expect(actions.findAllByProps({ children: 'Approve plan' })).toHaveLength(1) }) + + describe("a subject of a kind this build cannot draw: a newer Orca's", () => { + const NEEDS_NEWER_ORCA = 'This request needs a newer version of Orca.' + + async function renderNewer(detail: string | undefined) { + const onRespond = vi.fn(async () => true) + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { + title: 'Review proposed change', + ...(detail ? { detail } : {}), + // A subject kind a newer build wrote; this build draws only plans. + subject: JSON.parse('{"kind":"diff","path":"a.ts"}'), + prompt: { itemId: 'approval-1', expectedRevision: 2 }, + options: [ + { label: 'Approve', send: 'allow' }, + { label: 'Deny', send: 'deny' } + ] + }, + onRespond, + onCancel + }) + ) + }) + const actions = renderer!.root.findByProps({ testID: 'native-chat-approval-actions' }) + return { onRespond, onCancel, options: actions.findAllByType('Pressable') } + } + + it('with no detail: says so, answers nothing, and only its cancel reaches the host', async () => { + const { onRespond, onCancel, options } = await renderNewer(undefined) + expect( + renderer!.root.findByProps({ testID: 'native-chat-approval-needs-newer-orca' }).props + .children + ).toBe(NEEDS_NEWER_ORCA) + expect(options).toHaveLength(2) + expect(options.every((option) => option.props.disabled === true)).toBe(true) + const cancel = renderer!.root.findByProps({ accessibilityLabel: 'Cancel' }) + expect(cancel.props.disabled).toBe(false) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 2 }) + expect(onRespond).not.toHaveBeenCalled() + }) + + it('with a detail: shows it, and still approves nothing', async () => { + const { options } = await renderNewer('# Release') + const content = renderer!.root.findByProps({ testID: 'native-chat-approval-content' }) + expect(content.findAllByProps({ children: '# Release' })).toHaveLength(1) + expect(options.every((option) => option.props.disabled === true)).toBe(true) + }) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index 29f3e4219816..ab7dda7374e0 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -3,6 +3,10 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { ShieldQuestion, X } from 'lucide-react-native' import { MobileMarkdown } from '../components/MobileMarkdown' import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + isNewerApprovalSubject, + isPlanApprovalSubject +} from '../../../src/shared/agent-session-approval-subject' import type { MobileChatPermission } from './mobile-native-chat-permission' // Renders a detected agent permission ask as a card with tappable options. @@ -19,6 +23,8 @@ function MobileNativeChatPermissionImpl({ }): React.JSX.Element { const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) + // A newer Orca's subject: its detail is shown, and only the card's cancel answers. + const newerSubject = isNewerApprovalSubject(permission.subject) const hasContext = Boolean( permission.description || permission.decisionReason || @@ -93,7 +99,7 @@ function MobileNativeChatPermissionImpl({ {permission.matchedAskRule.source} ) : null} - {permission.subject?.kind === 'plan' ? ( + {isPlanApprovalSubject(permission.subject) ? ( {permission.subject.filePath ? ( @@ -103,6 +109,11 @@ function MobileNativeChatPermissionImpl({ ) : permission.detail ? ( {permission.detail} ) : null} + {newerSubject ? ( + + This request needs a newer version of Orca. + + ) : null} ) : null} @@ -114,11 +125,12 @@ function MobileNativeChatPermissionImpl({ style={({ pressed }) => [ styles.option, isPrimary ? styles.optionPrimary : styles.optionSecondary, - pressed && !submitting && styles.optionPressed + pressed && !submitting && styles.optionPressed, + newerSubject && styles.disabled ]} hitSlop={6} onPress={() => respond(option.send)} - disabled={submitting} + disabled={submitting || newerSubject} > {option.label} @@ -212,6 +224,9 @@ const styles = StyleSheet.create({ optionPressed: { opacity: 0.7 }, + disabled: { + opacity: 0.5 + }, optionText: { color: colors.textPrimary, fontSize: typography.bodySize, diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index d224c2e34a0e..ff2637f8a456 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -93,6 +93,9 @@ type Overrides = { keyboardInset?: number hasMore?: boolean onLoadEarlier?: () => void + status?: Parameters[0]['status'] + error?: string + readFailedFinally?: boolean } function assistantTurn(id: string, text: string): NativeChatMessage { @@ -214,6 +217,39 @@ describe('MobileNativeChatView', () => { }) } + // A read no retry gets past takes the whole pane, even over a transcript already on screen: its + // words alone, and nothing that could only be refused again. + it('leaves only the words of a read that failed for good, over a loaded transcript', async () => { + const loaded = [assistantTurn('m1', 'earlier reply')] + const words = 'This chat was saved by a newer Orca. Update Orca to open it.' + const shown = (text: string) => + renderer!.root.findAll((node) => node.type === 'Text' && node.props.children === text) + const composers = () => renderer!.root.findAll((node) => node.type === 'Composer') + const lists = () => renderer!.root.findAll((node) => node.type === 'FlatList') + const failure = { + messages: loaded, + folded: loaded, + status: 'error' as const, + error: words, + canStop: true, + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + // A resend answered unknown says nothing beside the read's words. + sendErrorMessage: 'Message unconfirmed — check chat before retrying' + } + await render({ ...failure, readFailedFinally: true }) + expect(shown(words)).toHaveLength(1) + expect(composers()).toHaveLength(0) + expect(lists()).toHaveLength(0) + expect(banners()).toHaveLength(0) + expect(renderer!.root.findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0) + + // A failure that can clear keeps the transcript and the composer. + await update({ ...failure, error: "Orca couldn't open this chat's history right now." }) + expect(listIds()).toEqual(['m1']) + expect(composers()).toHaveLength(1) + expect(banners()).toHaveLength(1) + }) + it('renders the route-reported failure verbatim', async () => { await render({ sendErrorMessage: 'Permission reply failed' }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 469abba53880..8e729e5e46b3 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -53,6 +53,8 @@ type Props = MobileQueuedSlotProps & { folded: NativeChatMessage[] status: MobileNativeChatStatus error?: string + /** The read failed for good (damage, a newer Orca's chat): its error takes the whole pane. */ + readFailedFinally?: boolean /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ agent?: string | null agentWorking?: boolean @@ -145,6 +147,7 @@ export function MobileNativeChatView({ folded, status, error, + readFailedFinally = false, agent, agentWorking, canStop = agentWorking, @@ -307,6 +310,18 @@ export function MobileNativeChatView({ const showLoading = status === 'loading' && messages.length === 0 const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) + const emptyStateView = emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + + // Whatever was already on screen: nothing here can act on a chat that cannot load, and its words + // say why once, as a fresh open's do. + if (readFailedFinally && emptyStateView) { + return {emptyStateView} + } return ( @@ -354,14 +369,7 @@ export function MobileNativeChatView({ turns.waitingRows, renderItem )} - ListEmptyComponent={ - emptyState ? ( - - {emptyState.title} - {emptyState.subtitle} - - ) : null - } + ListEmptyComponent={emptyStateView} /> {/* Jump-to-latest control. */} diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7ccbc10e2198..b5d7959eb460 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -55,13 +55,18 @@ class AgentSessionRpcResponseError extends Error { } } +/** The refusal a failed read met, from a thrown error or a stream's error frame. */ +export function agentSessionReadFailureRefusal(failure: unknown) { + return readAgentSessionErrorRefusal( + typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure + ) +} + /** A failed read of a chat's history as the pane shows it, from a thrown error or a stream's error * frame (`{ message, error }`): a thrown refusal's message is its bare code, so its words come * from the refusal in the error's data. */ export function agentSessionReadFailureText(failure: unknown): string { - const refusal = readAgentSessionErrorRefusal( - typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure - ) + const refusal = agentSessionReadFailureRefusal(failure) if (refusal) { return agentSessionWriteNoticeEnglish( agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), 'read-history') diff --git a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts index 065845640b3c..0a682f1c937a 100644 --- a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { + agentSessionReadFailureRefusal, agentSessionReadFailureText, callAgentSession, requestStructuredAgentSessionMutation @@ -43,6 +44,18 @@ describe('a refusal the host threw', () => { expect(agentSessionReadFailureText({ type: 'error', message: 'Connection interrupted' })).toBe( 'Connection interrupted' ) + // The refusal itself rides beside the words, so the view can tell a failure no retry gets past. + expect( + agentSessionReadFailureRefusal({ + type: 'error', + message: THROWN_JOURNAL_REFUSAL.message, + error: THROWN_JOURNAL_REFUSAL + }) + ).toEqual(THROWN_JOURNAL_REFUSAL.data.refusal) + expect(agentSessionReadFailureRefusal(thrown)).toEqual(THROWN_JOURNAL_REFUSAL.data.refusal) + expect( + agentSessionReadFailureRefusal({ type: 'error', message: 'Connection interrupted' }) + ).toBeUndefined() }) it("fails a write with the refusal's words, not as unconfirmed", async () => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 1f8bdae2d481..c3add0c67b4a 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -35,6 +35,9 @@ export type MobileNativeChatSession = { * wait for this to clear. */ transcriptLoading: boolean error?: string + /** The read failed for good (damage, or a newer Orca's chat): the error says why, once, and + * there is nothing to send into. */ + readFailedFinally?: boolean /** True when an older page may exist (the last read filled the window). */ hasMore: boolean /** Whether an older-history page is currently loading. */ diff --git a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx new file mode 100644 index 000000000000..594a84acf5fe --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx @@ -0,0 +1,129 @@ +// A host's queue waits on any pending prompt. While every pending prompt is an approval this build +// cannot answer, nothing here can settle it, so a send goes out without `delivery` and starts a +// turn, whose card cancel then works. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { RpcClient } from '../transport/rpc-client' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { + CAPABLE, + SESSION_ID, + fieldsOf, + mutationOk, + ok, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null +const sendRequest = vi.fn() +const client: RpcClient = { + sendRequest, + subscribe: vi.fn((_method, _params, onData) => { + listener = onData + return vi.fn() + }), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: SESSION_ID, + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'claude', + hostSupport: CAPABLE, + appendComposerText: () => true, + onSendError: vi.fn() + }) + return null +} + +function approval(subject: Record): AgentJournalRenderItem { + return JSON.parse( + JSON.stringify({ + itemId: `approval-${String(subject.kind)}`, + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review', + detail: null, + subject, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) +} + +async function sentDelivery(items: AgentJournalRenderItem[]): Promise { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + const event = snapshotEvent() + if (event.type === 'snapshot') { + event.page.items = items + } + act(() => listener?.(event)) + await act(async () => { + await hook!.sendWithOutcome('carry on') + }) + const send = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send') + return fieldsOf(send?.[1]).delivery +} + +beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? mutationOk({ clientMessageId: 'client-1' }) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null +}) + +it('sends without queueing while every pending prompt is one this build cannot answer', async () => { + expect(await sentDelivery([approval({ kind: 'diff', path: 'a.ts' })])).toBeUndefined() +}) + +it('still queues beside a prompt this build can answer', async () => { + expect( + await sentDelivery([ + approval({ kind: 'diff', path: 'a.ts' }), + approval({ kind: 'plan', text: 'do it' }) + ]) + ).toBe('queue-if-active') +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx new file mode 100644 index 000000000000..0c1c1a7f7115 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx @@ -0,0 +1,104 @@ +// A refused read of a chat's history: the phone keeps the refusal beside its words, so a failure no +// retry gets past (damage, a newer Orca's chat) shows no composer under it. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { CAPABLE, SESSION_ID, ok } from './use-mobile-structured-agent-session-queued.test-fixture' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null +const sendRequest = vi.fn() +const client: RpcClient = { + sendRequest, + subscribe: vi.fn((_method, _params, onData) => { + listener = onData + return vi.fn() + }), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: SESSION_ID, + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'claude', + hostSupport: CAPABLE, + appendComposerText: () => true, + onSendError: vi.fn() + }) + return null +} + +/** The stream's error frame for a refused read, as the RPC client hands it over. */ +function refusedRead(reason: string) { + const error = { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + } + return { type: 'error', message: error.message, error } +} + +async function readFailedWith(reason: string) { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(refusedRead(reason))) + return hook!.session +} + +beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + sendRequest.mockImplementation(async (method) => + method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + ) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null +}) + +it.each([ + ['journalCorrupt', 'Unable to load this chat.'], + ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] +])('reads %s as a failure no retry gets past, in its words', async (reason, words) => { + expect(await readFailedWith(reason)).toMatchObject({ + status: 'error', + error: words, + readFailedFinally: true + }) +}) + +it('reads a failure that can clear as one the chat keeps trying past', async () => { + expect(await readFailedWith('journalUnavailable')).toMatchObject({ + status: 'error', + readFailedFinally: false + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index b0ecfe9832c7..7bec053c9d67 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -5,6 +5,7 @@ import { projectStructuredAgentSessionMessages } from '../../../src/shared/struc import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' +import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { activeStructuredAgentSessionTurnId, isStructuredAgentSessionThinking @@ -239,6 +240,7 @@ export function useMobileStructuredAgentSession(args: { status, transcriptLoading: status === 'loading', error: state.error, + readFailedFinally: status === 'error' && isFinalAgentSessionReadRefusal(state.readRefusal), hasMore: state.hasOlder, loadingEarlier: loadingOlder, loadEarlier diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 9eca412c5411..b903e42de001 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -17,6 +17,7 @@ import { } from '../../../src/shared/structured-agent-session-reducer' import type { RpcClient } from '../transport/rpc-client' import { + agentSessionReadFailureRefusal, agentSessionReadFailureText, callAgentSession } from './mobile-structured-agent-session-rpc' @@ -138,6 +139,18 @@ export function useMobileStructuredAgentState(args: { }, [sessionKey] ) + // The refusal rides beside its words, so the view can tell a failure no retry gets past. + const applyReadFailure = useCallback( + (failure: unknown) => { + const refusal = agentSessionReadFailureRefusal(failure) + apply({ + type: 'error', + message: agentSessionReadFailureText(failure), + ...(refusal ? { refusal } : {}) + }) + }, + [apply] + ) const applyQueued = useCallback( (event: AgentSessionSubscribeEvent) => { @@ -187,7 +200,7 @@ export function useMobileStructuredAgentState(args: { }) const endStream = openTranscriptAfterHold(client, sessionId, held, (raw) => { if (typeof raw === 'object' && raw !== null && 'type' in raw && raw.type === 'error') { - apply({ type: 'error', message: agentSessionReadFailureText(raw) }) + applyReadFailure(raw) return } if (isSubscribeEvent(raw)) { @@ -212,7 +225,7 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey]) + }, [apply, applyQueued, applyReadFailure, client, connected, enabled, sessionId, sessionKey]) const loadEarlier = useCallback(() => { const current = stateRef.current @@ -280,7 +293,7 @@ export function useMobileStructuredAgentState(args: { })() .catch((error: unknown) => { if (isCurrentRead()) { - apply({ type: 'error', message: agentSessionReadFailureText(error) }) + applyReadFailure(error) } }) .finally(() => { @@ -288,7 +301,7 @@ export function useMobileStructuredAgentState(args: { setLoadingOlder(false) } }) - }, [apply, client, loadingOlder, sessionId, sessionKey]) + }, [apply, applyReadFailure, client, loadingOlder, sessionId, sessionKey]) // A window of only a subagent's rows draws nothing, and an empty list cannot be scrolled // to ask for more, so it reads back once from each such head. A first page can be one: an diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 44c94f777a21..613dd14fe084 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -1,10 +1,11 @@ // The structured composer's one send seam: a slash command dispatches as a // conversation command, and everything else goes out as an // `agentSession.send` — carrying `delivery: 'queue-if-active'` only when the -// host advertises the queue. +// host advertises the queue and no pending prompt is one this build cannot answer. import { useCallback } from 'react' import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -116,7 +117,11 @@ export function useMobileStructuredSendWithOutcome(args: { expectedRuntimeFence: currentFence, text, attachments: sendAttachments, - ...(queueCapable ? { delivery: 'queue-if-active' as const } : {}), + // The host's queue waits on any pending prompt; one this build cannot answer would hold + // the send forever, so it starts a turn, whose card cancel then works. + ...(queueCapable && !pendingPromptsAllUnanswerableHere(stateRef.current.items) + ? { delivery: 'queue-if-active' as const } + : {}), deadline, onError: onSendError }) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 0e17feb98747..101297a6cbbb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -104,10 +104,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. const journal = { - snapshot: () => ({ items }), + snapshot: () => ({ items, submissions: [] }), lastActivityAt: () => 1, - isReadOnly: false, cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal const pending: Promise[] = [] @@ -192,8 +192,8 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. const journal = { - isReadOnly: false, lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ @@ -206,7 +206,8 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { turnLifecycle: { turnId: 'turn-1', state: 'running' } } } - ] + ], + submissions: [] }) } as unknown as AgentSessionJournal const location = { diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index a865e1c1b76e..7eb09546e723 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -31,7 +31,6 @@ async function wiredSession() { let sequence = 0 const journal = { cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }), - isReadOnly: false, lastActivityAt: () => 1, snapshot: () => ({ items: [...run.journalItems.values()] @@ -46,7 +45,7 @@ async function wiredSession() { [ parent.sessionId, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, read-only flag, activity clock and snapshot served here. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, activity clock and snapshot served here. journal: journal as unknown as Journal, params: { location: { diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index c74e4741fc1a..cfb30d7363a0 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -6,6 +6,7 @@ import type { AgentJournalQuestionItem } from '../../shared/agent-session-journal-types' import { formatToolInput, truncateToolDetail } from '../../shared/native-chat-tool-summary' +import { isPlanApprovalSubject } from '../../shared/agent-session-approval-subject' import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { @@ -44,7 +45,7 @@ export function claudePromptIdentity(input: { } export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem { - const planSubject = prompt.subject?.kind === 'plan' ? prompt.subject : null + const planSubject = isPlanApprovalSubject(prompt.subject) ? prompt.subject : null const detail = truncateToolDetail(planSubject?.text ?? formatToolInput(prompt.input)) return boundJournalPromptBody({ kind: 'approval', diff --git a/src/main/codex/codex-close-turn-end.test.ts b/src/main/codex/codex-close-turn-end.test.ts index 1e3475301bf0..98ae89a7347e 100644 --- a/src/main/codex/codex-close-turn-end.test.ts +++ b/src/main/codex/codex-close-turn-end.test.ts @@ -62,7 +62,6 @@ function sessionWithRunningTurn() { fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexPromptRegistry(), options: new Map(), reportedOptions: {}, diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index a7baf6a14b59..454c3ccd4a01 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -67,7 +67,6 @@ describe('requested-close durable turn timing', () => { fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexPromptRegistry(), options: new Map(), reportedOptions: {}, diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a1..ae965795f7dd 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -234,7 +234,6 @@ export async function acquireCodexStructuredSession(input: { connection, ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), threadId: opened.threadId, - historyPath: opened.historyPath, historyMode: opened.historyMode, activeTurnIds: new Set(), prompts: acquisition.prompts, diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index e3211f320e85..2e553cb9e0b8 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -188,9 +188,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { fence: 7 }) ).rejects.toThrow('no live codex app-server') - expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( - '/rollouts/abc.jsonl' - ) + expect(adapter.backgroundTaskStops('session-1')).toBeDefined() await expect(adapter.closeSession('session-1')).resolves.toBe(false) expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) }) @@ -205,9 +203,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { codex.connections[0].handlers.onExit?.(new Error('the superseded child died')) expect(events.filter((event) => event.type === 'ended')).toHaveLength(endedBeforeStaleExit) - expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( - '/rollouts/abc.jsonl' - ) + expect(adapter.backgroundTaskStops('session-1')).toBeDefined() }) it('ignores Codex traffic that arrives after the session is gone', async () => { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 9276d670b4ce..0e674bf10de3 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -291,18 +291,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections).toHaveLength(0) }) - it('reports the rollout path Codex named, and null when it named none', async () => { - const withPath = fakeCodex() - const adapter = await acquired(withPath) - expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( - '/rollouts/abc.jsonl' - ) - - const withoutPath = fakeCodex({ 'thread/start': () => ({ thread: { id: THREAD_ID } }) }) - const bare = await acquired(withoutPath) - expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull() - }) - it('lets closeAll cancel and reap an acquisition still opening', async () => { const codex = fakeCodex() let releaseOpen = (): void => {} diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13a687a72cdc..6f67b68f8e63 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,8 +1,5 @@ import * as codexRewind from './codex-structured-rewind' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -284,10 +281,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap readOptions = (input: { sessionId: string; fence: number }) => readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - historyFilePath = async (input: { - identity: AgentSessionJournalIdentity - }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 9d80a0fa9829..4906b45a529a 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -100,7 +100,6 @@ describe('Codex structured session close lifecycle', () => { fence: 7, acquisitionGeneration: 'generation-1', threadId: THREAD, - historyPath: null, prompts, options: new Map(), reportedOptions: {}, diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index aac9e2946cf7..ad4935e5d4b4 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -57,7 +57,6 @@ function storeSession( fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexAcquisitionWindow().prompts, options: new Map(), reportedOptions: { model: 'gpt-live', effort: 'high' }, diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index d99ecc0305e1..4df25a9477ca 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -31,7 +31,6 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexAcquisitionWindow().prompts, options: new Map(), reportedOptions: { model: 'gpt-live', effort: 'high' }, @@ -137,7 +136,6 @@ describe('structured Codex session options', () => { expect( reportedCodexThreadOptions({ threadId: 'thread-1', - historyPath: null, model: 'gpt-live', effort: 'high' }) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index a47cb5a78fe2..6ddc11ac1f60 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -109,7 +109,6 @@ export type CodexSession = { fence: number acquisitionGeneration: string threadId: string - historyPath: string | null historyMode?: 'legacy' | 'paginated' /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index f9f2b78b7f9b..96359286b676 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -21,13 +21,6 @@ export function readCodexThreadId(payload: unknown): string | null { return nonEmptyString(record(root.thread)?.id) ?? nonEmptyString(root.threadId) } -/** Rollout file for the thread, when Codex reports one. Journal recovery reads - * it; a null just falls back to the existing session-file resolver. */ -export function readCodexThreadPath(payload: unknown): string | null { - const root = record(payload) - return root ? nonEmptyString(record(root.thread)?.path) : null -} - /** `turn/start` responses carry `turn.id`; `turn/started` notifications carry * the same under `turn`, and older builds put `turnId` on the envelope. */ export function readCodexTurnId(payload: unknown): string | null { diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index fede869a83c9..efd32b666cd5 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -95,7 +95,6 @@ describe('openCodexThread', () => { ).resolves.toEqual({ threadId: 'thread-standard', thread: { id: 'thread-standard' }, - historyPath: null, serviceTier: null }) }) diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 0f8b530d6240..19a3980cb208 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -10,15 +10,13 @@ import { type CodexAppServerConnection } from './codex-app-server-connection' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' -import { readCodexThreadId, readCodexThreadPath } from './codex-structured-thread-facts' +import { readCodexThreadId } from './codex-structured-thread-facts' export type CodexOpenedThread = { threadId: string /** The unsaved thread this new one was started in place of. */ supersededThreadId?: string thread?: Record - /** Rollout file Codex named, when it named one. */ - historyPath: string | null historyMode?: 'legacy' | 'paginated' model?: string effort?: string @@ -145,7 +143,6 @@ export async function openCodexThread( threadId, ...(supersededThreadId === undefined ? {} : { supersededThreadId }), thread, - historyPath: readCodexThreadPath(opened), ...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated' ? { historyMode: thread.historyMode } : {}), diff --git a/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts b/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts new file mode 100644 index 000000000000..063e5d48f825 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts @@ -0,0 +1,22 @@ +// Every approval subject kind the journal schema knows, against the kinds every supported client +// draws. A client from before unknown subjects were refused leaves Approve live for a kind it cannot +// draw, so a new kind ships only behind a client capability that withholds it from such clients. + +import { expect, it } from 'vitest' +import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from '../../../shared/agent-session-journal-schemas' + +/** Drawn by every supported client. */ +const SUBJECT_KINDS_EVERY_CLIENT_DRAWS = ['plan'] +/** Kinds the host withholds from clients without the capability; none exists yet (STA-9262). */ +const CAPABILITY_GATED_SUBJECT_KINDS: string[] = [] + +it('adds no approval subject kind without a client-capability gate', () => { + const ungated = [...AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS] + .filter((kind) => !CAPABILITY_GATED_SUBJECT_KINDS.includes(kind)) + .sort() + expect( + ungated, + 'New approval subject kinds need a client-capability gate first: older clients would let ' + + 'users approve it blind. See https://linear.app/stably/issue/STA-9262' + ).toEqual(SUBJECT_KINDS_EVERY_CLIENT_DRAWS) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts new file mode 100644 index 000000000000..acaaf99e0ae3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts @@ -0,0 +1,109 @@ +// Every closed set a journal row is read against: a row kind, a lifecycle mutation kind, and each +// enum, literal or discriminant in a body. A new row or mutation kind, or a bumped row version, +// makes older builds refuse the chat as a newer Orca's ("Update Orca to open it"); a new value in a +// body's closed set at the same version is damage to them ("Unable to load this chat"), where a +// turn's context usage is dropped instead. This snapshot sits beside the row version so that a +// change to either is deliberate. + +import { expect, it } from 'vitest' +import { z } from 'zod' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas' +import { KNOWN_MUTATION_KINDS, KNOWN_ROW_KINDS } from './journal-row-schema' + +type ClosedValue = string | number | boolean | null +type ClosedSets = Record + +/** Nodes that hold no closed set and nothing nested. */ +const LEAVES = new Set(['string', 'number', 'boolean', 'unknown', 'any', 'null', 'undefined']) + +function isClosedValue(value: unknown): value is ClosedValue { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) +} + +/** Walks a schema and collects, by path, every value a closed node allows. A plain union whose + * other arm takes any string for a discriminated arm's tag (blocks, goal states) is open there: + * older builds read a new tag as-is, so that path is listed apart. Throws on a node kind it does + * not know, so a new kind of schema cannot hide a closed set from this test. */ +function collectClosedSets( + schema: z.core.$ZodType, + path: string, + into: { closed: ClosedSets; catchAll: string[] } +): void { + const join = (key: string) => (path ? `${path}.${key}` : key) + if (schema instanceof z.ZodLiteral || schema instanceof z.ZodEnum) { + const kept = (into.closed[path] ??= []) + const values = schema instanceof z.ZodLiteral ? [...schema.values] : schema.options + for (const value of values) { + if (isClosedValue(value) && !kept.includes(value)) { + kept.push(value) + } + } + kept.sort((left, right) => String(left).localeCompare(String(right))) + } else if (schema instanceof z.ZodObject) { + for (const [key, field] of Object.entries(schema.shape)) { + collectClosedSets(field, join(key), into) + } + } else if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + collectClosedSets(schema.unwrap(), path, into) + } else if (schema instanceof z.ZodArray) { + collectClosedSets(schema.element, `${path}[]`, into) + } else if (schema instanceof z.ZodUnion) { + for (const option of schema.options) { + collectClosedSets(option, path, into) + } + for (const tag of catchAllTags(schema.options)) { + delete into.closed[join(tag)] + into.catchAll.push(join(tag)) + } + } else if (!LEAVES.has(schema._zod.def.type)) { + throw new Error(`no rule for a ${schema._zod.def.type} schema at ${path || 'the root'}`) + } +} + +/** Tags a discriminated arm keys on that another arm of the same union accepts as any string. */ +function catchAllTags(options: readonly z.core.$ZodType[]): string[] { + const tags = options.flatMap((option) => + option instanceof z.ZodDiscriminatedUnion ? [option.def.discriminator] : [] + ) + return tags.filter((tag) => + options.some( + (option) => option instanceof z.ZodObject && option.shape[tag] instanceof z.ZodString + ) + ) +} + +function journalClosedSets() { + const closed: ClosedSets = { + 'row.kind': [...KNOWN_ROW_KINDS.keys()].sort(), + 'row.mutations[].kind': [...KNOWN_MUTATION_KINDS.keys()].sort() + } + const into = { closed, catchAll: new Array() } + collectClosedSets(AgentJournalItemBodySchema, 'body', into) + return { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, ...into } +} + +/** Recorded with the row version it holds for. Change it only together with that decision. */ +const SNAPSHOT = { + v: 3, + closed: { + 'row.kind': ['dispatch', 'epoch', 'item', 'lifecycle-batch', 'submission', 'tombstone'], + 'row.mutations[].kind': ['item', 'tombstone'], + 'body.contextUsage.used.kind': ['estimate', 'report', 'unknown'], + 'body.contextUsage.used.categories[].deferred': [true] + }, + catchAll: ['body.blocks[].type', 'body.subject.kind', 'body.threadGoal.state', 'body.kind'] +} + +it('changes no closed set of a journal row without the row version', () => { + expect( + journalClosedSets(), + 'A new closed-set value without a row-version bump makes older builds call the chat ' + + 'damaged, where with the bump they say to update Orca. Bump v, then update this snapshot.' + ).toEqual(SNAPSHOT) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts deleted file mode 100644 index 8c4db243aa79..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// A repair drops what it cannot replay, and says so. -// -// Two things make a suffix unreplayable: a row this build cannot parse, and a -// sequence gap that makes every later row unanchored. The rejected suffix is -// DELETED; the load reports `corrupt`, and recovery rebuilds the epoch from -// provider history. Every case here asserts the same two halves: the live epoch -// holds only the replayable prefix, AND the epoch stays anchored so nothing -// replays a repaired journal as a clean timeline. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' -import { parseJournalRow, type JournalRow } from './journal-row-schema' -import type { openAgentSessionJournal } from './journal-store-factory' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - loadTestJournal, - liveTestJournalRows, - updateTestJournalRowJson, - deleteTestJournalRow -} from './journal-host-database-test-support' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function tick(): number { - clock += 1 - return clock -} - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function body(value: string): AgentJournalItemBody { - return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } -} - -function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -async function withJournalDatabase(run: (db: Database.Database) => void): Promise { - const opened = openTestJournalHostDatabase(root) - try { - run(opened.db) - } finally { - opened.close() - } -} - -/** The row replay anchors on, parsed exactly as replay parses it. */ -function firstLiveRow(): Promise { - let row: JournalRow | null = null - return withJournalDatabase((db) => { - const stored = liveTestJournalRows(db, IDENTITY.sessionId)[0] - const parsed = stored ? parseJournalRow(stored.rowJson) : null - row = parsed?.ok ? parsed.row : null - }).then(() => row) -} - -function liveSequences(): Promise { - let sequences: number[] = [] - return withJournalDatabase((db) => { - sequences = liveTestJournalRows(db, IDENTITY.sessionId).map((row) => row.seq) - }).then(() => sequences) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-repair-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('a malformed row', () => { - it('keeps the readable prefix live and drops the rest of the epoch', async () => { - const journal = await open() - await journal.appendItem(item(0), body('readable'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(1), body('unreadable'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(2), body('after the fault'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withJournalDatabase((db) => { - updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '{"not":"a row"}') - }) - - const reopened = await open() - expect(reopened.repair.malformedRows).toBe(1) - // 1..2 is the surviving prefix; 3 is the disclosure the repair appends. - expect(await liveSequences()).toEqual([1, 2, 3]) - }) - - it('discloses the line it could not read', async () => { - const journal = await open() - await journal.appendItem(item(0), body('readable'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(1), body('later'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withJournalDatabase((db) => { - updateTestJournalRowJson(db, IDENTITY.sessionId, 2, '}{') - }) - - const reopened = await open() - const disclosure = reopened - .snapshot() - .items.map((entry) => entry.body) - .find((entry) => entry.kind === 'status') - // Named for the client to word, with English for a client that can't. - expect(disclosure).toEqual({ - kind: 'status', - text: "Part of this chat's history couldn't be loaded.", - presentation: 'history-repaired' - }) - }) -}) - -describe('a sequence gap', () => { - it('drops every row after the hole and reports the epoch corrupt', async () => { - const journal = await open() - for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await journal.close() - // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves - // 5 and 6 valid but unanchored. - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 4) - }) - - const reopened = await open() - expect(await liveSequences()).toEqual([1, 2, 3]) - expect(reopened.repair).toEqual({ malformedRows: 0 }) - expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0'), body('m1')]) - }) - - // The prefix survives, so there is no emptied epoch to re-anchor and — a gap - // costing no malformed row — no disclosure either. Without a durable marker - // the next probe reads a contiguous anchored prefix and calls it clean, and - // the rows the repair deleted are never asked for again. - it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => { - const journal = await open() - for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await journal.close() - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 4) - }) - - const repaired = await open() - await repaired.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) - - // Same policy the emptied-epoch repair takes: a session that writes into the - // epoch owns it, and a later import must not replace rows the user has seen. - const writable = await open() - await writable.appendItem(item(9), body('typed after the repair'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await writable.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) - }) - - // The disclosure is the repair talking about itself, not the session writing: - // counting it as content would retire the marker the instant it was raised. - it('is not settled by the repair disclosure it appends for a malformed row', async () => { - const journal = await open() - for (let ordinal = 0; ordinal < 3; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await journal.close() - await withJournalDatabase((db) => { - updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') - }) - - const repaired = await open() - expect(repaired.repair.malformedRows).toBe(1) - await repaired.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) - }) -}) - -describe('a missing epoch row', () => { - // Sequence 1 is the anchor for the whole epoch. Validating from the first row - // that HAPPENS to remain declares the leftovers contiguous, and replay then - // renders a repaired journal as a clean timeline. - it('rejects the whole surviving range rather than declaring it contiguous', async () => { - const journal = await open() - await journal.appendItem(item(0), body('anchor'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendSubmission({ - clientMessageId: 'client-message-1', - payloadFingerprint: 'fingerprint-1', - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'the user typed this' }] - }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-message-1', - state: 'accepted', - providerIdentity: item(1), - fence: 1 - }) - await journal.close() - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 1) - }) - - const reopened = await open() - expect(reopened.repair).toEqual({ malformedRows: 0 }) - expect(reopened.snapshot().items).toEqual([]) - - // The epoch cannot be left row-less. An ordinary append would then take - // sequence 1, and replay would call that non-epoch row a clean timeline. - expect(await liveSequences()).toEqual([1]) - const anchor = await firstLiveRow() - expect(anchor).toMatchObject({ kind: 'epoch', reason: 'unreconcilable_prefix' }) - }) - - // The repair epoch is a placeholder for history it could not rebuild. Left - // clean it would end automatic recovery: the provider transcript is never - // consulted again and the dropped rows never come back. - it('keeps asking for provider history until the epoch has content of its own', async () => { - const journal = await open() - await journal.appendItem(item(0), body('anchor'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 1) - }) - - const repaired = await open() - await repaired.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) - - // A session that writes into the epoch owns it: its own rows are not a - // repair placeholder, and a later import must not replace them. - const writable = await open() - await writable.appendItem(item(1), body('typed after the repair'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await writable.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor.ts b/src/main/native-chat/agent-session-journal/journal-cursor.ts index bd43623ae497..3e79cf326a52 100644 --- a/src/main/native-chat/agent-session-journal/journal-cursor.ts +++ b/src/main/native-chat/agent-session-journal/journal-cursor.ts @@ -72,20 +72,15 @@ export function findSequenceGap( return null } -/** Rows appended after `cursor`, or the reset a client must take instead. A - * read-only journal always resets: this build cannot vouch for what it holds. */ +/** Rows appended after `cursor`, or the reset a client must take instead. */ export function readJournalSince( source: { state: { epoch: string; lastSequence: number; oldestSequence: number } rowsAfter: (afterSequence: number) => JournalRow[] - readOnly: boolean }, cursor: AgentJournalCursor, currentCursor: () => AgentJournalCursor ): JournalReadSince { - if (source.readOnly) { - return { ok: false, reset: 'schema_unreadable' } - } const resume = resolveJournalResume(source.state, cursor) if (!resume.ok) { return { ok: false, reset: resume.reset } diff --git a/src/main/native-chat/agent-session-journal/journal-damage.test.ts b/src/main/native-chat/agent-session-journal/journal-damage.test.ts new file mode 100644 index 000000000000..2ced3e4b2a99 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-damage.test.ts @@ -0,0 +1,245 @@ +// Damage fails the chat's load, and nothing is deleted. +// +// A row this build cannot parse, a row numbered against its key, a gap in the sequence and an epoch +// without its first row are each damage. The open refuses the chat as history that cannot be +// loaded, and every stored row is where it was. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import type { openAgentSessionJournal } from './journal-store-factory' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + liveTestJournalRows, + updateTestJournalRowJson, + deleteTestJournalRow, + SAVED_BY_NEWER_ORCA +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const UNLOADABLE = { + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(value: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +function withJournalDatabase(run: (db: Database.Database) => T): T { + const opened = openTestJournalHostDatabase(root) + try { + return run(opened.db) + } finally { + opened.close() + } +} + +function storedRows(): { seq: number; rowJson: string }[] { + return withJournalDatabase((db) => liveTestJournalRows(db, IDENTITY.sessionId)) +} + +/** A chat of an epoch row and five messages: sequences 1 to 6. */ +async function writeChat(): Promise { + const journal = await open() + for (let ordinal = 0; ordinal < 5; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + await journal.close() +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-damage-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('a damaged chat', () => { + it.each([ + [ + 'a row that is not a row', + (db: Database.Database) => updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') + ], + [ + 'a row of a known kind missing a field', + (db: Database.Database) => + updateTestJournalRowJson( + db, + IDENTITY.sessionId, + 3, + JSON.stringify({ ...JSON.parse(storedRowJson(db, 3)), itemId: 5 }) + ) + ], + [ + 'a row numbered against its key', + (db: Database.Database) => + updateTestJournalRowJson( + db, + IDENTITY.sessionId, + 3, + JSON.stringify({ ...JSON.parse(storedRowJson(db, 3)), seq: 9 }) + ) + ], + [ + 'a gap in the sequence', + (db: Database.Database) => deleteTestJournalRow(db, IDENTITY.sessionId, 4) + ], + ['no epoch row', (db: Database.Database) => deleteTestJournalRow(db, IDENTITY.sessionId, 1)] + ])('fails to load and keeps every row: %s', async (_name, damage) => { + await writeChat() + withJournalDatabase(damage) + const before = storedRows() + + await expect(open()).rejects.toMatchObject(UNLOADABLE) + await expect(open()).rejects.toMatchObject(UNLOADABLE) + + expect(storedRows()).toEqual(before) + }) + + it('names where the damage starts', async () => { + await writeChat() + withJournalDatabase((db) => deleteTestJournalRow(db, IDENTITY.sessionId, 4)) + + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ + newer: null, + damage: { sequence: 4, cause: 'sequence-gap' } + }) + }) + + it("reads past damage to a newer build's row, which fails the load as a newer Orca's instead", async () => { + await writeChat() + withJournalDatabase((db) => { + updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') + const newer = { ...JSON.parse(storedRowJson(db, 5)), kind: 'checkpoint' } + updateTestJournalRowJson(db, IDENTITY.sessionId, 5, JSON.stringify(newer)) + }) + const before = storedRows() + + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(storedRows()).toEqual(before) + }) +}) + +function storedRowJson(db: Database.Database, seq: number): string { + const row = liveTestJournalRows(db, IDENTITY.sessionId).find((entry) => entry.seq === seq) + if (!row) { + throw new Error(`no row at ${seq}`) + } + return row.rowJson +} + +describe('a row the reader would reject', () => { + // TypeScript accepts it; the persisted reader rejects a call id that is only spaces. + const blankCallId: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] + } + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + + it('is refused when it is written, and the chat still loads with every other row', async () => { + const journal = await open() + await journal.appendItem(item(0), body('before'), scope) + await expect(journal.appendItem(item(1), blankCallId, scope)).rejects.toMatchObject({ + code: 'journal_row_rejected' + }) + await journal.appendItem(item(2), body('after'), scope) + await journal.close() + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('before'), + body('after') + ]) + expect(storedRows().map((row) => row.seq)).toEqual([1, 2, 3]) + }) + + it('rolls back a whole epoch replacement that holds one, keeping the epoch it would replace', async () => { + const journal = await open() + await journal.appendItem(item(0), body('kept'), scope) + // The open journal's own database: closing it here would close the journal's too. + const liveRows = () => + liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId) + const before = liveRows() + + await expect( + journal.replaceEpochItems('legacy_import', 1, [ + { identity: item(5), body: body('replacement') }, + { identity: item(6), body: blankCallId } + ]) + ).rejects.toMatchObject({ code: 'journal_row_rejected' }) + + expect(liveRows()).toEqual(before) + await journal.appendItem(item(1), body('still writable'), scope) + await journal.close() + await expect(open()).resolves.toBeTruthy() + }) +}) + +describe('an epoch named with no rows at all', () => { + // A crash inside an older build's repair: the suffix deleted, its new epoch not yet published. + it('is founded afresh on open, with nothing deleted, and takes writes', async () => { + await writeChat() + withJournalDatabase((db) => { + for (const row of liveTestJournalRows(db, IDENTITY.sessionId)) { + deleteTestJournalRow(db, IDENTITY.sessionId, row.seq) + } + }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ + newer: null, + damage: null, + state: { lastSequence: 0 } + }) + + const journal = await open() + await journal.appendItem(item(0), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.close() + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('after')]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index e110b9221c44..00538fe75f9b 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -2,16 +2,16 @@ // // `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`. // `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so -// an append is one INSERT. `journal_repairs` carries at most one row per chat: the standing demand -// for a rebuild a partial repair leaves behind (see journal-repair-marker.ts). `journal_imports` +// an append is one INSERT. `journal_repairs` is only for an older build, which reads and writes it +// on this same schema version; this build does neither. `journal_imports` // records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and // `journal_set_aside` each chat whose per-chat file is not this build's history and is never read // again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the // retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version - * (`JournalRow.v`): a newer build can change either alone. A newer version opens read-only here, - * so every change stays additive. */ + * (`JournalRow.v`): a newer build can change either alone. A newer version is opened read-only here + * and its chats are refused as a newer Orca's, so every change stays additive. */ export const JOURNAL_DB_SCHEMA_VERSION = 4 /** The first version a release wrote; 1 and 2 only ever came from development builds. */ diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 677efc21c4d7..649cfccdf1a3 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -16,7 +16,6 @@ import { journalDatabasePath } from './journal-host-database' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { deleteJournalEpochRows, - deleteJournalRowSuffix, deleteUnpublishedJournalRows, insertJournalRow, iterateJournalEpochRows, @@ -130,7 +129,7 @@ describe('the host journal database open', () => { }) describe('journal row statements', () => { - it('serves replay, resume, suffix truncation and an epoch discard', () => { + it('serves replay, resume and an epoch discard', () => { const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { db.exec('BEGIN IMMEDIATE') @@ -149,9 +148,6 @@ describe('journal row statements', () => { 4, 5 ]) - expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2) - expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3]) - // Another chat in the same file is untouched by this chat's discard. deleteJournalEpochRows(db, 'session-1', 'epoch-1') expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 485e4d784174..3a79762ee2dd 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -20,7 +20,6 @@ export class JournalEpochController { serialize: (run: JournalWriteBody) => Promise database: () => JournalHostDatabase readOnly: () => boolean - setReadOnly: (readOnly: boolean) => void highestFence: () => number /** What of the live epoch's Stop and Resume a replacement restates. */ queuePauseRestatement: () => JournalQueuePauseRestatement @@ -42,8 +41,8 @@ export class JournalEpochController { } /** - * Every reason takes the same writable guard. A latched store refuses a roll - * like any other write, and `schema_unreadable` has no production caller. + * Every reason takes the same writable guard: a newer Orca's database refuses a roll like any + * other write, and `schema_unreadable` has no production caller. * * Serialized like every other write, so the discard cannot land between an * admitted append's sequence assignment and its commit. @@ -52,7 +51,6 @@ export class JournalEpochController { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId) this.start(reason, fence) - this.deps.setReadOnly(false) return this.deps.cursor() }) } diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 70efc8912837..1921bc82a459 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -1,8 +1,7 @@ // Republishing a live item set into a fresh epoch. // // One transaction: discard the old epoch's rows, insert the epoch row plus the -// replacement items, move the session projection, and retire any repair marker -// — this republished history is exactly what the marker was holding out for. +// replacement items, and move the session projection. import type { AgentJournalItemBody, @@ -13,7 +12,6 @@ import type { } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' -import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { @@ -97,7 +95,6 @@ export function replaceJournalEpoch(input: { if (retired !== null) { deleteJournalEpochRows(db, sessionId, retired) } - clearJournalRepairMarker(db, sessionId) for (const row of rows) { insertJournalRow(db, sessionId, row) } @@ -108,5 +105,5 @@ export function replaceJournalEpoch(input: { // live one. The caller adopts that immediately, or a later failure leaves the // live store writing into an epoch whose rows were just deleted. state.oldestSequence = 1 - input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) + input.onPublished({ state, newer: null, damage: null }) } diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 855f60ac3261..7f9abfc408f5 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -1,15 +1,13 @@ // Opening a new epoch. // // One transaction: discard every row of the superseded epoch, insert the new -// epoch row at sequence 1, move the session projection onto it, and retire any -// repair marker the superseded epoch was carrying. Superseded rows are DELETED -// rather than retained — nothing would ever shed them. +// epoch row at sequence 1, and move the session projection onto it. Superseded +// rows are DELETED rather than retained — nothing would ever shed them. import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' -import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { deleteJournalEpochRows, @@ -47,7 +45,6 @@ export function publishNewEpoch(input: { if (retired !== null) { deleteJournalEpochRows(db, sessionId, retired) } - clearJournalRepairMarker(db, sessionId) insertJournalRow(db, sessionId, row) publishJournalSessionEpoch(db, input.identity, input.epoch) }) @@ -58,5 +55,5 @@ export function publishNewEpoch(input: { const state = createJournalReducerState(sessionId, input.epoch) applyJournalRow(state, row) state.oldestSequence = 1 - input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) + input.onPublished({ state, newer: null, damage: null }) } diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index ed27ed6fa0b2..84fb299fb056 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -21,7 +21,9 @@ import { openTestJournalHostDatabase, loadTestJournal, deleteTestJournalRow, - insertTestJournalRowJson + insertTestJournalRowJson, + liveTestJournalRows, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -142,11 +144,8 @@ describe('a chat whose history is still in the pre-SQLite format', () => { expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl')) }) - // A repair's epoch is the marker that history was deleted and never rebuilt, - // and any row that is not the repair's own disclosure retires it. Appending - // here would silently stop the session ever asking the provider for that - // history — with the journal still holding none. - it('stays out of a journal this open just repaired', async () => { + // A damaged journal fails its open before this branch, so nothing is appended to it. + it('writes nothing into a damaged journal', async () => { const journal = await open() await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, @@ -154,10 +153,6 @@ describe('a chat whose history is still in the pre-SQLite format', () => { { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() - // Deleting the anchor leaves every row unanchored: replay keeps nothing, so - // the repair publishes an empty `unreconcilable_prefix` epoch and — costing - // no malformed row — appends no disclosure of its own. That is the one state - // where this branch and a repair meet. const opened = openTestJournalHostDatabase(root) try { deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) @@ -166,16 +161,22 @@ describe('a chat whose history is still in the pre-SQLite format', () => { } await writeRemnant() - const repaired = await open() - - expect(disclosure(repaired)).toBeNull() - // Still asking the provider for the history the repair dropped. - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + await expect(open()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ + damage: { sequence: 1, cause: 'no-epoch-row' } + }) + const stored = openTestJournalHostDatabase(root) + try { + expect(liveTestJournalRows(stored.db, IDENTITY.sessionId).map((row) => row.seq)).toEqual([2]) + } finally { + stored.close() + } }) - // A journal latched by a newer row reaches the same branch — and an append into one throws, - // which would make the session unopenable rather than read-only. - it('writes nothing into a journal latched by a newer row', async () => { + // A newer row fails the load before the empty-epoch branch would write a notice. + it("writes nothing into a newer Orca's journal, whose open is refused", async () => { const founded = await open() const epoch = founded.epoch await founded.close() @@ -197,10 +198,12 @@ describe('a chat whose history is still in the pre-SQLite format', () => { ) await writeRemnant() - const latched = await open() + const before = liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId) - expect(latched.isReadOnly).toBe(true) - expect(disclosure(latched)).toBeNull() + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)).toEqual( + before + ) }) // A row nothing projects is a row nobody reads. diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index dbe15b4709ed..ad151d10ec72 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -81,6 +81,14 @@ export function createTrackedJournalOpener(): TrackedJournalOpener { } /** What a fresh open of the chat would replay, read from the test state directory's database. */ +/** What an open of a chat a newer Orca saved is refused with. */ +export const SAVED_BY_NEWER_ORCA = { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } +} + export function loadTestJournal(stateDirectory: string, sessionId: string): JournalLoad | null { return replayJournal(openTestJournalHostDatabase(stateDirectory).db, sessionId) } diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index a38a37fd0c82..3d835c21b730 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -566,8 +566,7 @@ describe('import failures', () => { }) // A transcript with no decodable messages recovers nothing. Publishing an - // empty replacement would roll the epoch and drop whatever the journal held — - // including a repair's own anchor and disclosure. + // empty replacement would roll the epoch and drop whatever the journal held. it('leaves the epoch untouched when the transcript decodes to no messages', async () => { const journal = await open('codex', CODEX_SESSION) await journal.appendItem( diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index ba6a918cc6e4..bdb95a6fc8e7 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -73,7 +73,7 @@ export async function importLegacyTranscriptIntoJournal(input: { if (!prepared.ok) { return prepared } - // An empty import must preserve any existing repair anchor and disclosure. + // An empty import leaves the epoch as it stands. if (prepared.items.length === 0) { const current = input.journal.cursor() return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false } diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 787b1aa6491b..c61057ac1b90 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -9,11 +9,13 @@ import { createJournalOpenReadRefusals, journalOpenReadRefusal, journalOpenRefusal, - journalOpenRefusalError + journalOpenRefusalError, + failLoadOnUnloadableJournal } from './journal-open-failure' import { AgentSessionJournalError } from './journal-write-guards' import { journalDatabasePath } from './journal-host-database' import { replayJournal } from './journal-open' +import { createJournalReducerState } from './journal-reducer' import { recordingStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger-test-support' let root: string @@ -169,6 +171,68 @@ describe('createJournalOpenReadRefusals', () => { 'open-for-read' ]) }) + + // Where it failed rides as the refusal's cause, so a chat whose reason changes is logged again + // and the log names the row. + it('logs a chat again when its load fails for another reason, naming where each failed', () => { + const log = recordingStructuredAgentSessionLogger() + const refusals = createJournalOpenReadRefusals(log.logger) + const state = createJournalReducerState('session-1', 'epoch-1') + const refusedFor = (load: Parameters[1]) => { + try { + failLoadOnUnloadableJournal('session-1', load) + } catch (error) { + return error + } + throw new Error('the load was not refused') + } + const newer = refusedFor({ state, newer: { sequence: 7 }, damage: null }) + const damaged = refusedFor({ + state, + newer: null, + damage: { sequence: 3, cause: 'sequence-gap' } + }) + + for (const error of [newer, newer, damaged, damaged]) { + refusals.refusal('session-1', error) + } + + const causes = log.entries.map((entry) => { + const error = entry.fields.error + return error instanceof Error && error.cause instanceof Error ? error.cause.message : '' + }) + expect(causes).toEqual([ + expect.stringContaining("newer Orca's row at sequence 7"), + expect.stringContaining('damaged at sequence 3: sequence-gap') + ]) + }) + + // A load refused on its rows arrives already classified, and is logged once per session too. + it("logs a load refused as a newer Orca's chat, or as damage, once per session until it opens", () => { + const log = recordingStructuredAgentSessionLogger() + const refusals = createJournalOpenReadRefusals(log.logger) + const newer = journalOpenRefusalError( + new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote session-1') + ) + for (let attempt = 0; attempt < 3; attempt += 1) { + expect(refusals.refusal('session-1', newer)).toBe(newer) + } + expect(log.entries.map((entry) => entry.fields.sessionId)).toEqual(['session-1']) + refusals.refusal('session-2', newer) + refusals.forget('session-1') + refusals.refusal('session-1', newer) + expect(log.entries.map((entry) => entry.fields.sessionId)).toEqual([ + 'session-1', + 'session-2', + 'session-1' + ]) + // A refusal that is not about loading the history is not this door's to log. + refusals.refusal( + 'session-3', + agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) + ) + expect(log.entries).toHaveLength(3) + }) }) // Only an update gets past a journal a newer Orca wrote, so it has a reason of its own: a client diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index d154e1b29e69..8ae45ca4a311 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -6,12 +6,12 @@ import type { AgentSessionRefusalReason } from '../../../shared/agent-session-re import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import { AgentSessionRefusalError, - agentSessionRefusalError, isAgentSessionRefusalError, refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' +import type { JournalLoad } from './journal-open' import { AgentSessionJournalError } from './journal-write-guards' import type { StructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' @@ -25,6 +25,32 @@ export class JournalImportMismatchError extends Error { override readonly name = 'JournalImportMismatchError' } +/** A history whose rows are not what they promise: a row no build wrote, a gap, no epoch row. */ +export class JournalDamageError extends Error { + override readonly name = 'JournalDamageError' +} + +/** The one way a chat's load fails on its rows, wherever they are read, and every row is left where + * it is. A newer Orca's row is refused as one only an update opens, and wins over damage beside + * it; damage is refused as history that cannot be loaded. */ +export function failLoadOnUnloadableJournal(sessionId: string, load: JournalLoad): void { + if (load.newer) { + throw journalOpenRefusalError( + new AgentSessionJournalError( + 'journal_read_only', + `journal of ${sessionId} holds a newer Orca's row at sequence ${load.newer.sequence}` + ) + ) + } + if (!load.damage) { + return + } + const { sequence, cause } = load.damage + throw journalOpenRefusalError( + new JournalDamageError(`journal of ${sessionId} is damaged at sequence ${sequence}: ${cause}`) + ) +} + /** A database only an unreleased development build wrote: left as found, never migrated. */ export class JournalUnreleasedSchemaError extends Error { override readonly name = 'JournalUnreleasedSchemaError' @@ -33,14 +59,15 @@ export class JournalUnreleasedSchemaError extends Error { // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 -/** Unusable only where proven: damage the storage reports, a copy that did not verify, or a file - * only an unreleased build wrote. Anything unproven can clear. */ +/** Unusable only where proven: damage the storage or the rows show, a copy that did not verify, + * or a file only an unreleased build wrote. Anything unproven can clear. */ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { let current = error for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { if ( isSqliteCorruption(current) || current instanceof JournalImportMismatchError || + current instanceof JournalDamageError || current instanceof JournalUnreleasedSchemaError ) { return 'journalCorrupt' @@ -94,13 +121,17 @@ export function journalOpenRefusal(error: unknown): AgentSessionWireRefusal { return refuse('agent_session_journal_unreadable', { reason }, message) } -/** The same refusal, thrown: for a host that could not open the journal at all. */ +/** The same refusal, thrown: for a host that could not open the journal at all. It keeps what + * failed as its cause, for the log. */ export function journalOpenRefusalError(error: unknown): AgentSessionRefusalError { if (isAgentSessionRefusalError(error)) { return error } const { reason, message } = journalOpenFailureWords(error) - return agentSessionRefusalError('agent_session_journal_unreadable', { reason }, message) + return new AgentSessionRefusalError( + refuse('agent_session_journal_unreadable', { reason }, message), + { cause: error } + ) } /** @@ -127,19 +158,33 @@ const MAX_LOGGED_SESSIONS = 256 */ export function createJournalOpenReadRefusals(logger: StructuredAgentSessionLogger) { const logged = new Map() + /** Whether this failure is news for the session, noting it if so. */ + const firstSeen = (sessionId: string, failure: string): boolean => { + const repeat = logged.get(sessionId) === failure + // Past the cap a new session logs every failure rather than evict another's. + if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { + logged.set(sessionId, failure) + } + return !repeat + } return { refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { if (isAgentSessionRefusalError(error)) { + // A load refused on its rows (damage, a newer Orca's chat) arrives classified. + const { refusal } = error + const cause = error.cause instanceof Error ? error.cause.message : String(error.cause) + if ( + refusal.code === 'agent_session_journal_unreadable' && + firstSeen(sessionId, `${refusal.details?.reason}:${cause}`) + ) { + logOpenForReadFailure(logger, sessionId, error) + } return error } const reason = journalRefusalReason(error) const failure = `${reason}:${error instanceof Error ? error.message : String(error)}` - const repeat = logged.get(sessionId) === failure - // Past the cap a new session logs every failure rather than evict another's. - if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { - logged.set(sessionId, failure) - } - return unreadableRefusal(error, reason, repeat ? null : { logger, sessionId }) + const log = firstSeen(sessionId, failure) ? { logger, sessionId } : null + return unreadableRefusal(error, reason, log) }, /** The session opened or closed: its next failure is news. */ forget: (sessionId: string): void => { @@ -153,11 +198,21 @@ function unreadableRefusal( reason: JournalRefusalReason, log: { logger: StructuredAgentSessionLogger; sessionId: string } | null ): AgentSessionRefusalError { - log?.logger.warn('opening the conversation for a read failed', { + if (log) { + logOpenForReadFailure(log.logger, log.sessionId, error) + } + const code = 'agent_session_journal_unreadable' + return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) +} + +function logOpenForReadFailure( + logger: StructuredAgentSessionLogger, + sessionId: string, + error: unknown +): void { + logger.warn('opening the conversation for a read failed', { scope: 'open-for-read', - sessionId: log.sessionId, + sessionId, error }) - const code = 'agent_session_journal_unreadable' - return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) } diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 4e64b6468241..2c066601ea7a 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -2,9 +2,10 @@ // epoch's rows are folded through the reducer in sequence order. // // There is no snapshot to anchor to and no superseded-epoch rows to drop — a -// roll deletes them in the same transaction that publishes the new epoch. A gap -// in the surviving sequence is corruption, and the caller rolls the epoch -// rather than rendering a partial timeline. +// roll deletes them in the same transaction that publishes the new epoch. A row +// this build cannot parse, a gap, or a first row that is not the epoch's is +// damage, and a row a newer Orca wrote is one this build cannot place: the load +// names either and the open fails on it. Nothing is deleted. import type Database from '../../sqlite/sync-database' import { @@ -17,27 +18,25 @@ import { readJournalRowsAfter, readJournalSessionEpoch } from './journal-row-table' -import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' -import { pendingJournalRepairSequence } from './journal-repair-marker' -import { isJournalStopOrResumeRow, parseJournalRow, type JournalRow } from './journal-row-schema' +import { parseJournalRow, type JournalRow } from './journal-row-schema' /** Every epoch row is sequence 1, and no compaction moves that floor. */ const FIRST_JOURNAL_SEQUENCE = 1 +/** Where a journal stops being one this build can read as written. */ +export type JournalDamage = { + /** The first sequence that is missing, or not what the rows before it promise. */ + sequence: number + cause: 'unparseable-row' | 'misnumbered-row' | 'sequence-gap' | 'no-epoch-row' +} + export type JournalLoad = { state: JournalReducerState - /** A row from a future schema, or of a kind this build does not know, was met: no writes, no - * deletion. */ - readOnly: boolean - /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ - corrupt: boolean - /** Rows dropped because they failed to parse or name another sequence than their key (an - * unreadable row latches `readOnly`, never counted here). The store discloses these in the - * timeline. */ - malformedRows: number - /** Directory-internal: the first sequence of an unusable suffix. The store - * deletes from here before it accepts a write; a probe leaves it alone. */ - truncateFrom?: number + /** The first row a newer Orca wrote: a future row version, or a row kind this build does not + * know. Only an update opens the chat. */ + newer: { sequence: number } | null + /** Set when the history is damaged; a newer build's row wins over damage beside it. */ + damage: JournalDamage | null } /** Replays one chat from the host's database. Returns null when the chat has no journal yet. */ @@ -46,20 +45,8 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal if (epoch === null) { return null } - return foldJournalRows({ - sessionId, - epoch, - repairedFrom: pendingJournalRepairSequence(db, sessionId, epoch), - rows: iterateJournalEpochRows(db, sessionId, epoch) - }) -} - -/** Folds one epoch's stored rows, in sequence order, wherever they are stored. */ -function foldJournalRows( - input: JournalRowFoldInput & { rows: Iterable<{ seq: number; rowJson: string }> } -): JournalLoad { - const fold = startJournalRowFold(input) - for (const entry of input.rows) { + const fold = startJournalRowFold({ sessionId, epoch }) + for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) { if (!fold.add(entry)) { break } @@ -67,85 +54,63 @@ function foldJournalRows( return fold.finish() } -type JournalRowFoldInput = { - sessionId: string - epoch: string - /** The sequence a pending repair on this epoch left free. */ - repairedFrom: number | null -} - /** The same fold, fed a row at a time, for a caller that yields between batches of rows. */ -export function startJournalRowFold(input: JournalRowFoldInput): { +export function startJournalRowFold(input: { sessionId: string; epoch: string }): { /** False once the fold has stopped: the rest of the rows are not read. */ add: (entry: { seq: number; rowJson: string }) => boolean finish: () => JournalLoad } { - const { repairedFrom } = input const state = createJournalReducerState(input.sessionId, input.epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE - let gapSequence: number | undefined - let unanchoredSequence: number | undefined - let anchor: Extract | undefined - let repairHasContent = false - let providerHasContent = false - let malformedRows = 0 - let latched = false - let truncateFrom: number | undefined + let damage: JournalDamage | null = null + let anchored = false + let newer: { sequence: number } | null = null + let empty = true const add = (entry: { seq: number; rowJson: string }): boolean => { + empty = false const parsed = parseJournalRow(entry.rowJson) - // A body naming another sequence than its key is malformed there: writes number past the key. - if (!parsed.ok || parsed.row.seq !== entry.seq) { - truncateFrom = entry.seq - latched = !parsed.ok && parsed.unreadable - malformedRows = latched ? 0 : 1 + if (!parsed.ok && parsed.unreadable) { + newer = { sequence: entry.seq } return false } - const row = parsed.row - // Parse past a gap so an unreadable future row still latches read-only. - if (gapSequence !== undefined) { + // Damage is read past, so a newer build's row further on still says to update. + if (!parsed.ok) { + damage ??= { sequence: entry.seq, cause: 'unparseable-row' } return true } + // A body naming another sequence than its key: writes would number past the key. + if (parsed.row.seq !== entry.seq) { + damage ??= { sequence: entry.seq, cause: 'misnumbered-row' } + return true + } + if (damage) { + return true + } + const row = parsed.row if (row.seq !== expectedSequence) { - gapSequence = row.seq + const cause = expectedSequence === FIRST_JOURNAL_SEQUENCE ? 'no-epoch-row' : 'sequence-gap' + damage = { sequence: expectedSequence, cause } return true } expectedSequence += 1 - if (row.seq === FIRST_JOURNAL_SEQUENCE) { - if (row.kind === 'epoch') { - anchor = row - } else { - unanchoredSequence = row.seq - } - } - if (!anchor) { + if (row.seq === FIRST_JOURNAL_SEQUENCE && row.kind !== 'epoch') { + damage = { sequence: row.seq, cause: 'no-epoch-row' } return true } + anchored = true applyJournalRow(state, row) - const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID - // A Stop or Resume is no history, so it never reads as a rebuilt or provider-backed epoch. - if (!disclosure && !isJournalStopOrResumeRow(row)) { - repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom - providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 - } return true } const finish = (): JournalLoad => { - // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows. - truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom state.oldestSequence = FIRST_JOURNAL_SEQUENCE - return { - state, - readOnly: latched, - corrupt: - gapSequence !== undefined || - malformedRows > 0 || - (!latched && !anchor) || - (repairedFrom !== null && !repairHasContent) || - (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent), - malformedRows, - ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {}) - } + // An epoch with no rows at all holds nothing to lose: the open founds a fresh one over it. + const found = + damage ?? + (anchored || empty + ? null + : { sequence: FIRST_JOURNAL_SEQUENCE, cause: 'no-epoch-row' as const }) + return { state, newer, damage: newer ? null : found } } return { add, finish } } @@ -160,8 +125,8 @@ export function journalRowsAfterReader( return (afterSequence) => readJournalRowsAfterCursor(db, sessionId, epoch, afterSequence, limit) } -/** Rows after a cursor, in sequence order. Stops at the first row this build - * cannot parse, exactly as replay does. */ +/** Rows after a cursor, in sequence order. Stops at the first row this build cannot parse: rows + * past it are never served. */ export function readJournalRowsAfterCursor( db: Database.Database, sessionId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index ebe2da08a55b..6c291c3a3783 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -2,7 +2,10 @@ // that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that // reappears after a downgrade is set aside on disk, and the chat keeps this build's history. -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import type * as NodeFs from 'node:fs' import { existsSync, rmSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' @@ -23,10 +26,11 @@ import { liveTestJournalRows, loadTestJournal, openTestJournalHostDatabase, - readTestJournalRows + readTestJournalRows, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import { importPerSessionJournal } from './journal-per-session-import' +import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' @@ -170,7 +174,7 @@ function alteringFirstCopiedRead( } } -const losingFirstCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(1)) +const losingLastCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(0, -1)) /** Every row still there and still parsing, but the reply's words changed. */ const garblingCopiedReply = alteringFirstCopiedRead((rows) => @@ -385,7 +389,7 @@ describe('importing a per-chat journal', () => { database, identity: IDENTITY, legacyDirectory: legacyDir(), - openSource: losingFirstCopiedRow + openSource: losingLastCopiedRow } const before = await readFile(legacyJournalDatabaseFile(legacyDir())) @@ -410,6 +414,75 @@ describe('importing a per-chat journal', () => { expect(rowCount(database.db)).toBe(rows.length) }) + it('opens a file whose chat has no rows as a fresh chat, losing nothing', async () => { + const { epoch } = await historyRows() + await writeLegacyJournal(epoch, []) + const database = openTestJournalHostDatabase(root) + const db = new Database(legacyJournalDatabaseFile(legacyDir())) + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) + db.close() + + await expect( + previewPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir() }) + ).resolves.toBeNull() + const journal = await openChat() + + expect(journal.snapshot().items).toEqual([]) + expect(journal.cursor().sequence).toBe(1) + }) + + it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => { + const { epoch, rows } = await historyRows() + // A gap: the reply's row is gone from the file. + await writeLegacyJournal( + epoch, + rows.filter((row) => row.seq !== 2) + ) + const database = openTestJournalHostDatabase(root) + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + const unloadable = { + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + } + const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } + + await expect(previewPerSessionJournal(input)).rejects.toMatchObject(unloadable) + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(importPerSessionJournal(input)).rejects.toMatchObject(unloadable) + } + await expect(openChat()).rejects.toMatchObject(unloadable) + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect(rowCount(database.db)).toBe(0) + expect( + database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() + ).toMatchObject({ total: 0 }) + }) + + it("keeps a newer Orca's file whole, copies none of it, and refuses the chat as a newer Orca's", async () => { + const { epoch, rows } = await historyRows() + const last = rows.at(-1)! + const newer = { + ...last, + rowJson: JSON.stringify({ + ...JSON.parse(last.rowJson), + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 + }) + } + await writeLegacyJournal(epoch, [...rows.slice(0, -1), newer]) + const database = openTestJournalHostDatabase(root) + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } + + await expect(previewPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(importPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(openChat()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect(rowCount(database.db)).toBe(0) + }) + // A copy that keeps every count but not every byte is no copy: the file is all there is. it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => { const { epoch, rows } = await historyRows() diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts index 5f85fdded437..66f1f139ea32 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts @@ -9,10 +9,10 @@ // The copy runs in bounded batches, each its own transaction, yielding the event loop between them. // The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader // sees them. Once they read back as the file does (every row's sequence, time and bytes), one -// transaction publishes the chat's pointer with its repair and import markers, so the chat is -// imported all at once or not at all. A try that stops midway leaves only unpublished rows, which -// the next try deletes before it copies again. A copy that does not read back as the file is never -// published: the file stays, and the chat is refused as unreadable. +// transaction publishes the chat's pointer with its import marker, so the chat is imported all at +// once or not at all. A try that stops midway leaves only unpublished rows, which the next try +// deletes before it copies again. A copy that does not read back as the file, or a file whose +// history is damaged, is never published: the file stays, and the chat is refused as unloadable. // // Only after that commit is the file deleted, its connection closed first. A read that fails // leaves the file where it is for the next open, and the open is refused rather than served empty: @@ -24,8 +24,12 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import type { JournalHostDatabase } from './journal-host-database' -import type { JournalLoad } from './journal-open' -import { JournalImportMismatchError, journalOpenRefusalError } from './journal-open-failure' +import { startJournalRowFold, type JournalLoad } from './journal-open' +import { + failLoadOnUnloadableJournal, + JournalImportMismatchError, + journalOpenRefusalError +} from './journal-open-failure' import { legacyJournalDatabaseFile } from './journal-paths' import { planPerSessionImport, @@ -42,7 +46,6 @@ import { legacyRowBatches, openLegacySource, readLegacyHead, - readLegacyRepair, retireLegacyJournal, type ImportBatch } from './journal-per-session-source' @@ -57,10 +60,6 @@ import { const INSERT_ROW = 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' -const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) -VALUES (?, ?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET - epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at` type PerSessionJournalImportDeps = { openSource?: (path: string) => Database.Database @@ -151,7 +150,8 @@ async function importOnce(input: ImportInput): Promise @@ -173,16 +173,18 @@ export async function previewPerSessionJournal( return null } const loaded = await foldLegacyJournal(source, sessionId, legacy) - return loaded.corrupt || loaded.readOnly || loaded.truncateFrom !== undefined ? null : loaded + failLoadOnUnloadableJournal(sessionId, loaded) + // An empty one is copied now, so its open founds the chat's epoch in the host's database. + return loaded.state.lastSequence === 0 ? null : loaded } finally { source.close() } } /** - * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Once - * the rows read back as the file does, one transaction publishes the pointer with the chat's repair - * marker and the import marker. + * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Each + * batch is folded as it is copied, so a damaged file is refused before anything is published. Once + * the rows read back as the file does, one transaction publishes the pointer and the import marker. */ async function copyLegacyJournal( input: ImportInput, @@ -191,13 +193,15 @@ async function copyLegacyJournal( ): Promise { const { sessionId } = input.identity const { epoch } = legacy - const repair = readLegacyRepair(source, sessionId) const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS + const fold = startJournalRowFold({ sessionId, epoch }) + let folding = true let first = true for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { if (!first) { await yieldToEventLoop() } + folding &&= batch.rows.every(fold.add) // Unsynced: no reader follows these rows, and the publish's synced commit covers them. input.database.unsyncedTransaction((db) => { if (first) { @@ -212,17 +216,15 @@ async function copyLegacyJournal( }) first = false } + const copied = fold.finish() + if (copied.damage || copied.newer) { + // Never published, so the copy goes; the file it came from stays. + input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId)) + } + failLoadOnUnloadableJournal(sessionId, copied) await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) input.database.transaction((db) => { publishJournalSessionEpoch(db, input.identity, epoch) - if (repair) { - db.prepare(UPSERT_REPAIR).run( - sessionId, - repair.epoch, - repair.content_from, - repair.repaired_at - ) - } writePerSessionImportMarker(db, sessionId, legacy) }) } diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts index dc548e2f3dd0..a58a1de3bf5d 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts @@ -4,11 +4,9 @@ import { rmdirSync, rmSync } from 'node:fs' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import Database from '../../sqlite/sync-database' -import type { SqliteRow } from '../../sqlite/sqlite-statement' import { startJournalRowFold, type JournalLoad } from './journal-open' import { legacyJournalDatabaseFile } from './journal-paths' import type { PerSessionJournalHead } from './journal-per-session-reimport' -import { pendingJournalRepairSequence } from './journal-repair-marker' /** The newest per-chat file shape any build wrote. */ const LEGACY_JOURNAL_SCHEMA_VERSION = 2 @@ -21,8 +19,6 @@ const SELECT_LEGACY_TIP = const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?` const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?" -const SELECT_LEGACY_REPAIR = - 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' type ImportedRow = { seq: number; ts: number; rowJson: string } export type ImportBatch = { rows: ImportedRow[]; last: boolean } @@ -81,27 +77,13 @@ export function* legacyRowBatches( } } -/** The per-chat repair marker, as stored; v1 files predate the table. */ -export function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null { - if (!source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')) { - return null - } - return source.prepare(SELECT_LEGACY_REPAIR).get(sessionId) ?? null -} - /** The chat as its file holds it, folded the way a replay folds the host's database. */ export async function foldLegacyJournal( source: Database.Database, sessionId: string, legacy: PerSessionJournalHead ): Promise { - const fold = startJournalRowFold({ - sessionId, - epoch: legacy.epoch, - repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') - ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) - : null - }) + const fold = startJournalRowFold({ sessionId, epoch: legacy.epoch }) let first = true for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) { // A batch per turn: a large chat's file read in one task holds up everything else at startup. diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index fe7b974d77eb..4aa275994342 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -5,6 +5,7 @@ import type { AgentJournalQuestion, AgentJournalQuestionItem } from '../../../shared/agent-session-journal-types' +import { isPlanApprovalSubject } from '../../../shared/agent-session-approval-subject' import { boundInlineText, boundPayload, @@ -68,16 +69,20 @@ export function boundJournalPromptBody( : { ruleContent: boundPromptText(body.matchedAskRule.ruleContent) }) } }), + // A newer Orca's subject is carried as it was: rebuilt as a plan, it would be a different + // request, and its fields are not this build's to bound. ...(body.subject === undefined ? {} : { - subject: { - kind: 'plan', - text: boundPromptText(body.subject.text), - ...(body.subject.filePath === undefined - ? {} - : { filePath: boundPromptText(body.subject.filePath) }) - } + subject: isPlanApprovalSubject(body.subject) + ? { + kind: 'plan', + text: boundPromptText(body.subject.text), + ...(body.subject.filePath === undefined + ? {} + : { filePath: boundPromptText(body.subject.filePath) }) + } + : body.subject }), detail: body.detail === null ? null : boundPromptText(body.detail), options: boundPromptOptions(body.options) diff --git a/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts b/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts deleted file mode 100644 index 838463333008..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts +++ /dev/null @@ -1,35 +0,0 @@ -// What a repair tells the user it did. -// -// The identity is a constant because replay reads it back: an epoch holding -// nothing but its anchor and this row is a repair that has not been -// reconstructed yet, not a timeline. - -import { agentSessionHostStatusBody } from '../../../shared/agent-session-host-status-rows' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalPlainStatusItem -} from '../../../shared/agent-session-journal-types' - -/** One stable identity, so a reopen upserts the same row instead of adding one. */ -export const JOURNAL_REPAIR_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = { - provider: 'orca', - clientMessageId: 'journal-malformed-lines' -} - -export const JOURNAL_REPAIR_DISCLOSURE_ITEM_ID = agentJournalItemKey( - JOURNAL_REPAIR_DISCLOSURE_IDENTITY -) - -export type JournalRepairDisclosure = { - identity: AgentJournalItemIdentity - body: AgentJournalPlainStatusItem -} - -/** Disclosed when a repair skipped a row it could not read. */ -export function journalRepairDisclosure(): JournalRepairDisclosure { - return { - identity: JOURNAL_REPAIR_DISCLOSURE_IDENTITY, - body: agentSessionHostStatusBody('history-repaired') - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts deleted file mode 100644 index 785339456712..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts +++ /dev/null @@ -1,65 +0,0 @@ -// The standing demand for a rebuild that a repair leaves behind. -// -// A repair that empties the epoch republishes an `unreconcilable_prefix` anchor, -// and replay reads that back as history still owed. A repair that KEEPS a prefix -// has no such anchor to publish and — for a plain sequence gap — no malformed -// row to disclose either, so nothing on disk would record that the deleted -// suffix was never reconstructed. This marker is that record, written in the -// SAME transaction as the deletion: a crash between the two would otherwise -// leave the rows gone with nothing left asking for them back. -// -// It records the first sequence at which the epoch would hold content of its -// own again, because it retires under exactly the rule the emptied-epoch anchor -// takes: a fresh epoch carries the rebuild, and a session that writes past that -// sequence owns the epoch and stops the retry. - -import type Database from '../../sqlite/sync-database' -import type { JournalHostDatabase } from './journal-host-database' -import { deleteJournalRowSuffix } from './journal-row-table' - -const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?' -const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) -VALUES (?, ?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET - epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at` -const DELETE_REPAIR = 'DELETE FROM journal_repairs WHERE session_id = ?' - -/** - * The sequence a pending repair on THIS epoch left free, or null when none is - * pending. Epoch-scoped: a marker raised on an epoch that has since been - * superseded says nothing about the live one. - */ -export function pendingJournalRepairSequence( - db: Database.Database, - sessionId: string, - epoch: string -): number | null { - const row = db.prepare(SELECT_REPAIR).get(sessionId) as - | { epoch?: string; content_from?: number } - | undefined - return row?.epoch === epoch ? (row.content_from ?? null) : null -} - -/** Retires the marker. Called from inside the epoch transactions, whose new - * epoch is the rebuilt history the marker was holding out for. */ -export function clearJournalRepairMarker(db: Database.Database, sessionId: string): void { - db.prepare(DELETE_REPAIR).run(sessionId) -} - -/** Drop the rejected suffix and record that it is owed, atomically. */ -export function deleteJournalRepairedSuffix(input: { - database: JournalHostDatabase - sessionId: string - epoch: string - /** First sequence of the rejected suffix. */ - fromSeq: number - /** First sequence left free once the suffix is gone. */ - contentFrom: number - now: number -}): number { - return input.database.transaction((db) => { - const deleted = deleteJournalRowSuffix(db, input.sessionId, input.epoch, input.fromSeq) - db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) - return deleted - }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index 623c97445a61..9dcced9944af 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -104,7 +104,6 @@ describe('context facts on replayed turn rows', () => { await journal.close() const reopened = await open() - expect(reopened.repair.malformedRows).toBe(0) expect(reopened.snapshot().items.map((item) => item.body)).toEqual(written) expect(written).toHaveLength(FACTS.length) }) @@ -132,7 +131,6 @@ describe('context facts on replayed turn rows', () => { } const reopened = await open() - expect(reopened.repair.malformedRows).toBe(0) expect(reopened.snapshot().items.map((item) => item.body)).toEqual([ turn('turn-0'), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 1d013255b215..b72a24d70bb3 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -290,8 +290,8 @@ describe('producer linkage on the persisted row', () => { it('writes and reads the bundle back without bumping the schema version', () => { const row = roundTrip(true) expect(row).toMatchObject(linkage) - // Deliberately NOT a version bump: an unknown `v` is unreadable and latches - // the host read-only, while an unknown KEY is simply ignored by an older host. + // Deliberately NOT a version bump: an unknown `v` is unreadable and costs an older + // host the chat, while an unknown KEY is simply ignored by an older host. expect(row?.v).toBe(AGENT_SESSION_JOURNAL_SCHEMA_VERSION) }) @@ -373,7 +373,7 @@ describe('producer linkage on the persisted row', () => { expect(mutations[0]).toMatchObject(linkage) expect(mutations[1] && 'agentId' in mutations[1]).toBe(false) // The same batch without the stamp writes the same version: an older host - // ignores the unknown keys rather than latching the journal read-only. + // ignores the unknown keys rather than losing the chat to an unknown `v`. expect(row.v).toBe( build({ kind: 'item', identity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE }).v ) @@ -421,7 +421,7 @@ describe('producer linkage on the persisted row', () => { expect(parsed.ok && 'agentId' in parsed.row).toBe(false) }) - it('leaves a strict prompt shape able to parse, because linkage rides the row', () => { + it('parses a prompt shape with linkage on the row', () => { const question = { ...BASE, v: 3, @@ -440,10 +440,9 @@ describe('producer linkage on the persisted row', () => { expect(parsed.ok).toBe(true) }) - it('rejects the same row when linkage is put INSIDE the strict shape', () => { - // The positive control for the test above: proof the strictness it avoids is - // real, rather than the row parsing for some unrelated reason. This is why - // the bundle rides the row base and never a body. + it('reads the same row with linkage put INSIDE a prompt option, ignoring the key there', () => { + // Older builds read prompt options strictly and dropped the journal from such a row, which is + // why the bundle rides the row base and never a body. This build ignores and keeps the key. const smuggled = { ...BASE, v: 3, @@ -457,6 +456,8 @@ describe('producer linkage on the persisted row', () => { resolution } } - expect(parseJournalRow(JSON.stringify(smuggled)).ok).toBe(false) + const parsed = parseJournalRow(JSON.stringify(smuggled)) + expect(parsed.ok && parsed.row.kind === 'item' && parsed.row.body).toEqual(smuggled.body) + expect(parsed.ok && 'agentId' in parsed.row).toBe(false) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..f0ebac8622bf 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -2,8 +2,8 @@ // // The journal is append-only, so migration is upcasting on read and never an // in-place rewrite. A row whose version this build does not understand is -// UNREADABLE, not skippable: the caller must degrade to read-only rather than -// render a partial timeline or compact past a row it cannot interpret. +// UNREADABLE, not skippable: the caller fails the load as a newer Orca's chat rather +// than render a partial timeline or compact past a row it cannot interpret. // // A row whose KIND this build does not know is UNREADABLE the same way, and kept // on disk, when it has the envelope every row keeps: a non-empty `epoch`, an @@ -12,6 +12,14 @@ // rule delete the journal from an unknown kind, so a new kind either ships its // reader first and is written only once no supported build lacks that reader, // or is written at a bumped `v`. +// +// Inside a known row with that envelope the same holds a level down. A lifecycle mutation of a kind +// this build does not know, or a sent message of a kind it does not know, is UNREADABLE, whatever +// else in the row fails; unusable annotations are dropped. A body of a kind it does not know is +// READABLE and drawn by nobody (agent-session-journal-schemas.ts). Any other failure is damage, and +// the chat fails to load with every row kept (journal-open.ts). Builds from before this rule delete +// the journal from such a row, so the same reader-first-or-`v` rule applies to a new mutation or +// sent-message kind. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -24,17 +32,19 @@ import { type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { - isAdmissibleAgentJournalItemBody, - isAdmissibleAgentJournalMessageBody -} from '../../../shared/agent-session-journal-schemas' -import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' + readAgentJournalItemBody, + readAgentJournalMessageBody, + type AgentJournalContentVerdict +} from '../../../shared/agent-session-journal-body-admission' +import { isJournalTag } from '../../../shared/agent-session-journal-open-union' import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' +import { dropUnusableRowAnnotations } from './journal-row-unusable-annotations' -/** Producer linkage rides the row BASE rather than the body: the two nested - * prompt shapes are `.strict()`, so an unknown key on a body would make the - * whole row parse as malformed. It is also deliberately not a `v` bump — an - * unknown `v` makes a row unreadable and latches the host read-only, while an - * unknown KEY is ignored below, so an older host reads a stamped row and +/** Producer linkage rides the row BASE rather than the body: older builds read + * the two nested prompt shapes strictly, so an unknown key there made the whole + * row parse as malformed. It is also deliberately not a `v` bump — an + * unknown `v` makes a row unreadable (released older hosts keep the chat read-only; + * this build fails its load), while an unknown KEY is ignored below, so an older host reads a stamped row and * behaves exactly as it does today. */ type JournalRowBase = AgentJournalProducerLinkage & { /** Schema version of THIS row. */ @@ -219,85 +229,11 @@ export function parseJournalRow(line: string): JournalRowParse { return { ok: false, unreadable: true } } const upcast = upcastRow(record, version) - dropUnusableProducerLinkage(upcast) - dropUnusableTurnScope(upcast) - if (upcast.kind === 'lifecycle-batch' && Array.isArray(upcast.mutations)) { - for (const mutation of upcast.mutations) { - if (isPlainObject(mutation)) { - dropUnusableProducerLinkage(mutation) - dropUnusableTurnScope(mutation) - } - } - } - dropUnusableContextUsage(upcast) + dropUnusableRowAnnotations(upcast) if (isJournalRow(upcast)) { return { ok: true, row: upcast } } - // A newer build's kind is placed by the envelope every row keeps; one without it is damage. - const { kind } = upcast - const unknownKind = typeof kind === 'string' && kind !== '' && !KNOWN_ROW_KINDS.has(kind) - return { ok: false, unreadable: unknownKind && hasJournalRowEnvelope(upcast) } -} - -/** Linkage ids this build cannot trust, removed from a row it still keeps. - * - * Deliberately NOT part of `isJournalRow`: rejecting a row there drops it from - * the timeline, so a validator tightened against one bad field becomes a - * whole-store kill switch. Dropping the field degrades the row to the - * session's own agent — what every row said before linkage existed — while - * keeping the content, which is always the safer direction. An `agentId` that - * survives is a real one: the reader scopes on PRESENCE, so `''` or a - * non-string left in place would hide the row from its own author for good. */ -function dropUnusableProducerLinkage(record: Record): void { - for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { - const value = record[field] - if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { - delete record[field] - } - } - if (record.attempt !== undefined && !Number.isInteger(record.attempt)) { - delete record.attempt - } -} - -/** A scope this build cannot place, removed like unusable linkage: the row then reads as one - * written before scopes existed, and the reducer derives its scope. */ -function dropUnusableTurnScope(record: Record): void { - const scope = record.turnScope - if ( - scope !== undefined && - !( - isPlainObject(scope) && - (scope.kind === 'thread' || - (scope.kind === 'turn' && - typeof scope.turnItemId === 'string' && - scope.turnItemId.length > 0)) - ) - ) { - delete record.turnScope - } -} - -/** Context facts this build cannot read, removed from the turn row that carries - * them. Same reasoning as linkage: they are an annotation on the turn, and - * rejecting the row for them would truncate the journal from that row on. */ -function dropUnusableContextUsage(record: Record): void { - const bodies = [ - record.kind === 'item' ? record.body : undefined, - ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) - ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) - : []) - ] - for (const body of bodies) { - if ( - isPlainObject(body) && - body.kind === 'turn' && - body.contextUsage !== undefined && - !isAdmissibleAgentSessionContextUsage(body.contextUsage) - ) { - delete body.contextUsage - } - } + return { ok: false, unreadable: journalRowContent(upcast) === 'unreadable' } } /** Read-time upcast chain. Each step raises a row exactly one version. */ @@ -322,46 +258,84 @@ function isPlainObject(value: unknown): value is Record { * Render BODIES are the exception and validate against the canonical deep * schema — their nested shapes are dereferenced unguarded all the way to the * rendered surface, so a JSON-valid corruption must fail here, not there. */ +function journalRowContent(record: Record): AgentJournalContentVerdict { + if (!hasJournalRowEnvelope(record)) { + return 'malformed' + } + const { kind } = record + const contentCheck = typeof kind === 'string' ? KNOWN_ROW_KINDS.get(kind) : undefined + if (contentCheck) { + return contentCheck(record) + } + // A newer build's kind is placed by the envelope every row keeps. + return isJournalTag(kind) ? 'unreadable' : 'malformed' +} + function isJournalRow(record: Record): record is JournalRow { - const fieldCheck = typeof record.kind === 'string' ? KNOWN_ROW_KINDS.get(record.kind) : undefined - return fieldCheck !== undefined && hasJournalRowEnvelope(record) && fieldCheck(record) + return journalRowContent(record) === 'readable' +} + +/** A newer build's content anywhere wins over damage beside it: never delete what it wrote. */ +function combinedContent(...verdicts: AgentJournalContentVerdict[]): AgentJournalContentVerdict { + if (verdicts.includes('unreadable')) { + return 'unreadable' + } + return verdicts.includes('malformed') ? 'malformed' : 'readable' +} + +function fieldsContent(hold: boolean): AgentJournalContentVerdict { + return hold ? 'readable' : 'malformed' } /** Each kind's own fields, keyed by every kind the union holds: a kind without a check here fails * to compile, never reads as a newer build's kind. */ -const ROW_FIELD_CHECK_BY_KIND: Record< +const ROW_CONTENT_CHECK_BY_KIND: Record< JournalRow['kind'], - (record: Record) => boolean + (record: Record) => AgentJournalContentVerdict > = { - epoch: (record) => typeof record.reason === 'string' && isPlainObject(record.providerHandle), + epoch: (record) => + fieldsContent(typeof record.reason === 'string' && isPlainObject(record.providerHandle)), item: (record) => - typeof record.itemId === 'string' && - Number.isInteger(record.revision) && - isAdmissibleAgentJournalItemBody(record.body), - tombstone: (record) => typeof record.itemId === 'string' && Number.isInteger(record.revision), + combinedContent( + fieldsContent(typeof record.itemId === 'string' && Number.isInteger(record.revision)), + readAgentJournalItemBody(record.body) + ), + tombstone: (record) => + fieldsContent(typeof record.itemId === 'string' && Number.isInteger(record.revision)), submission: (record) => - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.payloadFingerprint === 'string' && - isPlainObject(record.providerHandle) && - isAdmissibleAgentJournalMessageBody(record.body), + combinedContent( + fieldsContent( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) + ), + readAgentJournalMessageBody(record.body) + ), dispatch: (record) => - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.state === 'string' && - record.state.length > 0 && - (record.providerItemId === null || typeof record.providerItemId === 'string') && - (record.reason === null || typeof record.reason === 'string'), - 'lifecycle-batch': (record) => - typeof record.settlementId === 'string' && - record.settlementId.length > 0 && - Array.isArray(record.mutations) && - record.mutations.length > 0 && - record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && - Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && - record.mutations.every(isLifecycleMutation) + fieldsContent( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.state === 'string' && + record.state.length > 0 && + (record.providerItemId === null || typeof record.providerItemId === 'string') && + (record.reason === null || typeof record.reason === 'string') + ), + 'lifecycle-batch': (record) => { + const mutations = Array.isArray(record.mutations) ? record.mutations : [] + return combinedContent( + fieldsContent( + typeof record.settlementId === 'string' && + record.settlementId.length > 0 && + mutations.length > 0 && + mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && + Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ), + ...mutations.map(lifecycleMutationContent) + ) + } } -const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_FIELD_CHECK_BY_KIND)) +export const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_CONTENT_CHECK_BY_KIND)) /** The fields every row keeps whatever its kind: its epoch, its place in it, its writer, its time. */ function hasJournalRowEnvelope(record: Record): boolean { @@ -375,18 +349,32 @@ function hasJournalRowEnvelope(record: Record): boolean { ) } -function isLifecycleMutation(value: unknown): value is JournalLifecycleMutation { - if (!isPlainObject(value) || typeof value.itemId !== 'string') { - return false +/** Each mutation kind's own fields, keyed like the row kinds and for the same reason. */ +const MUTATION_CONTENT_CHECK_BY_KIND: Record< + JournalLifecycleMutation['kind'], + (mutation: Record) => AgentJournalContentVerdict +> = { + item: (mutation) => + combinedContent( + fieldsContent(typeof mutation.itemId === 'string' && Number.isInteger(mutation.revision)), + readAgentJournalItemBody(mutation.body) + ), + tombstone: (mutation) => + fieldsContent(typeof mutation.itemId === 'string' && Number.isInteger(mutation.revision)) +} +export const KNOWN_MUTATION_KINDS = new Map(Object.entries(MUTATION_CONTENT_CHECK_BY_KIND)) + +/** The kind first: a newer build's kind needs none of the fields this build's kinds have. */ +function lifecycleMutationContent(value: unknown): AgentJournalContentVerdict { + if (!isPlainObject(value)) { + return 'malformed' } - if (value.kind === 'tombstone') { - return Number.isInteger(value.revision) + const { kind } = value + const contentCheck = typeof kind === 'string' ? KNOWN_MUTATION_KINDS.get(kind) : undefined + if (contentCheck) { + return contentCheck(value) } - return ( - value.kind === 'item' && - Number.isInteger(value.revision) && - isAdmissibleAgentJournalItemBody(value.body) - ) + return isJournalTag(kind) ? 'unreadable' : 'malformed' } /** Approximate on-disk cost of a row, used for the per-session size bound. */ diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index 4665a0571150..a558252a8994 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -5,7 +5,8 @@ // always named: `SELECT *` is uncacheable and can drop a column. import type Database from '../../sqlite/sync-database' -import { serializeJournalRow, type JournalRow } from './journal-row-schema' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import { AgentSessionJournalError } from './journal-write-guards' export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string } @@ -19,7 +20,6 @@ const INSERT_ROW = const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` -const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?' const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ? AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)` @@ -44,6 +44,15 @@ export function insertJournalRow( row: JournalRow ): number { const rowJson = serializeJournalRow(row) + // A row the reader rejects would fail the chat's next load, so it is never written: the throw + // rolls back the caller's transaction. + const readBack = parseJournalRow(rowJson) + if (!readBack.ok || readBack.row.seq !== row.seq) { + throw new AgentSessionJournalError( + 'journal_row_rejected', + `the ${row.kind} row for ${sessionId} would not read back, so it was not written` + ) + } db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson) return Buffer.byteLength(rowJson, 'utf8') } @@ -102,14 +111,3 @@ export function deleteJournalEpochRows( export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void { db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId) } - -/** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */ -export function deleteJournalRowSuffix( - db: Database.Database, - sessionId: string, - epoch: string, - fromSeq: number -): number { - const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq) - return Number(deleted.changes ?? 0) -} diff --git a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts new file mode 100644 index 000000000000..6c8811eb4a5b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts @@ -0,0 +1,83 @@ +// Annotations on a persisted row this build cannot use, removed from a row it still keeps, before +// the row's content is checked (journal-row-schema.ts). + +import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' + +export function dropUnusableRowAnnotations(record: Record): void { + dropUnusableProducerLinkage(record) + dropUnusableTurnScope(record) + if (record.kind === 'lifecycle-batch' && Array.isArray(record.mutations)) { + for (const mutation of record.mutations) { + if (isPlainObject(mutation)) { + dropUnusableProducerLinkage(mutation) + dropUnusableTurnScope(mutation) + } + } + } + dropUnusableContextUsage(record) +} + +/** Linkage ids this build cannot trust, removed from a row it still keeps. + * + * Deliberately NOT part of `isJournalRow`: rejecting a row there drops it from + * the timeline, so a validator tightened against one bad field becomes a + * whole-store kill switch. Dropping the field degrades the row to the + * session's own agent — what every row said before linkage existed — while + * keeping the content, which is always the safer direction. An `agentId` that + * survives is a real one: the reader scopes on PRESENCE, so `''` or a + * non-string left in place would hide the row from its own author for good. */ +function dropUnusableProducerLinkage(record: Record): void { + for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { + const value = record[field] + if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { + delete record[field] + } + } + if (record.attempt !== undefined && !Number.isInteger(record.attempt)) { + delete record.attempt + } +} + +/** A scope this build cannot place, removed like unusable linkage: the row then reads as one + * written before scopes existed, and the reducer derives its scope. */ +function dropUnusableTurnScope(record: Record): void { + const scope = record.turnScope + if ( + scope !== undefined && + !( + isPlainObject(scope) && + (scope.kind === 'thread' || + (scope.kind === 'turn' && + typeof scope.turnItemId === 'string' && + scope.turnItemId.length > 0)) + ) + ) { + delete record.turnScope + } +} + +/** Context facts this build cannot read, removed from the turn row that carries + * them. Same reasoning as linkage: they are an annotation on the turn, and + * rejecting the row for them would truncate the journal from that row on. */ +function dropUnusableContextUsage(record: Record): void { + const bodies = [ + record.kind === 'item' ? record.body : undefined, + ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) + ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) + : []) + ] + for (const body of bodies) { + if ( + isPlainObject(body) && + body.kind === 'turn' && + body.contextUsage !== undefined && + !isAdmissibleAgentSessionContextUsage(body.contextUsage) + ) { + delete body.contextUsage + } + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index 8eee42a70d03..7b8e9fe5935a 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -46,8 +46,8 @@ afterEach(async () => { }) /** One epoch's rows, folded as an open folds them. */ -function fold(rows: readonly JournalRow[], repairedFrom: number | null = null) { - const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH, repairedFrom }) +function fold(rows: readonly JournalRow[]) { + const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH }) for (const row of rows) { folding.add({ seq: row.seq, rowJson: serializeJournalRow(row) }) } @@ -110,13 +110,13 @@ describe("a Stop's event and a Resume", () => { expect(projected).toMatchObject({ ok: true, batch: { items: [], removedItemIds: [] } }) }) - it('are no provider history: an unreconcilable epoch holding only them still reads corrupt', () => { - expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume'])).corrupt).toBe(true) - expect(fold(epochWith('unreconcilable_prefix', ['stop', 'item'])).corrupt).toBe(false) - }) - - it('are no rebuilt history: a pending repair followed only by them still reads corrupt', () => { - expect(fold(epochWith('handle_forked', ['stop', 'resume']), 2).corrupt).toBe(true) - expect(fold(epochWith('handle_forked', ['stop', 'item']), 2).corrupt).toBe(false) + // An older build founded `unreconcilable_prefix` epochs when it deleted history; this one opens + // such an epoch as it stands. + it('fold as history in an epoch an older build founded after deleting rows', () => { + expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume']))).toMatchObject({ + newer: null, + damage: null, + state: { lastSequence: 3 } + }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index ba8796964692..dc270bc97a6d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -40,14 +40,9 @@ export type JournalStoreHost = { database: () => JournalHostDatabase state: () => JournalReducerState readOnly: () => boolean - setReadOnly: (readOnly: boolean) => void cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void commit: (row: JournalRow) => void - /** Records whether the open's replay found an unusable prefix. */ - setOpenedCorrupt: (corrupt: boolean) => void - malformedRows: () => number - setMalformedRows: (count: number) => void journal: () => AgentSessionJournal enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise } @@ -72,7 +67,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal serialize: host.serialize, database: host.database, readOnly: host.readOnly, - setReadOnly: host.setReadOnly, highestFence: () => host.state().highestFence, queuePauseRestatement: () => journalQueuePauseRestatement( diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index c8674e0dbd5b..cac1517c4091 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -8,33 +8,17 @@ import { journalFileFormatRemnantDisclosure } from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' -import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure' +import { failLoadOnUnloadableJournal } from './journal-open-failure' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' -/** What any of this file's disclosures hands the store — a repair's, or the - * pre-SQLite notice's. Same shape, and neither is only a repair. */ -type JournalDisclosure = JournalRepairDisclosure - -export function journalStoreLoadedFields(loaded: JournalLoad) { - return { - state: loaded.state, - readOnly: loaded.readOnly, - malformedRows: loaded.malformedRows - } -} +type JournalDisclosure = ReturnType export async function openJournalStoreState(input: { + sessionId: string legacyDirectory: string replay: () => JournalLoad | null - /** Drops the rejected suffix and records the rebuild it owes, in ONE - * transaction. Corruption is not preserved; replay keeps reporting `corrupt` - * until provider history republishes the epoch or the session writes past - * `contentFrom`, the first sequence the repair left free. */ - deleteSuffix: (fromSeq: number, contentFrom: number) => number start: () => void adopt: (loaded: JournalLoad) => void - /** Republishes an anchor row for an epoch a repair emptied. */ - publishRepairEpoch: () => void appendItem: ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, @@ -42,46 +26,24 @@ export async function openJournalStoreState(input: { ) => Promise agent: AgentType highestFence: () => number - malformedRows: () => number - setMalformedRows: (count: number) => void - readOnly: () => boolean }): Promise { const loaded = input.replay() - if (!loaded) { + // An epoch named but holding no row (a crash inside an older build's repair) has nothing to + // keep, so it is founded afresh like a chat with no journal; no row is deleted. + if (!loaded || (!loaded.newer && !loaded.damage && loaded.state.lastSequence === 0)) { input.start() await discloseFileFormatRemnant(input) return } + failLoadOnUnloadableJournal(input.sessionId, loaded) input.adopt(loaded) - if (loaded.truncateFrom !== undefined && !loaded.readOnly) { - input.deleteSuffix(loaded.truncateFrom, loaded.state.lastSequence + 1) - } - // A repair that took every live row leaves the epoch with no anchor. Publish - // one before anything can append into it: an ordinary row at sequence 1 would - // replay as a clean timeline and hide that the history was never rebuilt. - if (!loaded.readOnly && loaded.state.lastSequence === 0) { - input.publishRepairEpoch() - // The replacement epoch adopts a clean load; what this open's repair did is - // still the answer `repair` and the disclosure below owe the caller. - input.setMalformedRows(loaded.malformedRows) - } - if (input.malformedRows() > 0 && !input.readOnly()) { - const disclosure = journalRepairDisclosure() - await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) - } await settleStaleSubagentRosters(input, loaded) // Founding the epoch and appending the row are two transactions, and a // committed epoch sends every later open down this branch instead. Anything // that interrupts between them — a quit during startup restore, a failed // append — would otherwise lose the message for good. An epoch holding nothing // is exactly the state that append was owed, so offer it again. - // - // Never onto a repair, though: `loaded.state` is the PRE-repair load, so a - // journal this open just emptied looks identical. The repair's epoch is the - // marker that its history was deleted and never rebuilt, and any row that is - // not the repair's own disclosure retires it — this row would silently stop - // the session ever asking the provider for that history again. - if (!loaded.corrupt && loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { + if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { await discloseFileFormatRemnant(input) } } @@ -98,11 +60,7 @@ async function discloseFileFormatRemnant(input: { fence: number ) => Promise highestFence: () => number - readOnly: () => boolean }): Promise { - if (input.readOnly()) { - return - } const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory) if (!transcriptPath) { return @@ -111,13 +69,7 @@ async function discloseFileFormatRemnant(input: { await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) } -/** - * Retires a `working` subagent roster the previous host never got to settle. - * - * Skipped on a corrupt load: that journal is still owed a rebuild from provider - * history, and content written past the repair's free sequence retires the - * demand for it. - */ +/** Retires a `working` subagent roster the previous host never got to settle. */ async function settleStaleSubagentRosters( input: { appendItem: ( @@ -126,13 +78,9 @@ async function settleStaleSubagentRosters( fence: number ) => Promise highestFence: () => number - readOnly: () => boolean }, loaded: JournalLoad ): Promise { - if (input.readOnly() || loaded.corrupt) { - return - } for (const revision of staleSubagentRosterRevisions(loaded.state.items.values())) { await input.appendItem(revision.identity, revision.body, input.highestFence()) } diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index 8fce8457b630..d8c44f964bdb 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -1,18 +1,17 @@ // Bringing a store's in-memory state up from disk. // // Split out of the store for the same reason its collaborators were: this is the -// ORDERING between replay, suffix repair and disclosure, and none of it belongs +// ORDERING between import, replay and disclosure, and none of it belongs // to the store's public surface. Every step here reads or writes through the // same host the collaborators use, so the store keeps the state and this owns // the sequence. import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' -import { replayJournal, type JournalLoad } from './journal-open' -import { createJournalReducerState } from './journal-reducer' +import { replayJournal } from './journal-open' +import { failLoadOnUnloadableJournal, journalOpenRefusalError } from './journal-open-failure' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' -import { deleteJournalRepairedSuffix } from './journal-repair-marker' import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' import { AgentSessionJournalError } from './journal-write-guards' @@ -26,7 +25,13 @@ export async function restoreJournalStore( legacyDirectory: host.legacyDirectory } if (source.database.readOnly) { - return restoreFromNewerDatabase(host, source) + // A newer Orca's database: nothing in it is read as this build's, and nothing is written. + throw journalOpenRefusalError( + new AgentSessionJournalError( + 'journal_read_only', + `agent-session journal for ${host.identity.sessionId} is in a newer Orca's database` + ) + ) } // A restore reads a chat still in its per-chat file from there, and copies it before its first use. const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null @@ -37,6 +42,7 @@ export async function restoreJournalStore( if (!imported) { throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`) } + failLoadOnUnloadableJournal(host.identity.sessionId, imported) host.adopt(imported) }) } else { @@ -44,66 +50,15 @@ export async function restoreJournalStore( await importPerSessionJournal(source) } return openJournalStoreState({ + sessionId: host.identity.sessionId, legacyDirectory: host.legacyDirectory, - replay: () => { - const loaded = preview ?? replayJournal(host.database().db, host.identity.sessionId) - host.setOpenedCorrupt(loaded?.corrupt ?? false) - return loaded - }, - deleteSuffix: (fromSeq, contentFrom) => - deleteJournalRepairedSuffix({ - database: host.database(), - sessionId: host.identity.sessionId, - epoch: host.state().epoch, - fromSeq, - contentFrom, - now: host.now() - }), + replay: () => preview ?? replayJournal(host.database().db, host.identity.sessionId), start: () => collaborators.epochController.start('session_created', 0), - // `unreconcilable_prefix` is the durable statement that this epoch exists - // because a repair emptied one: replay reads it back and keeps asking for - // provider history until the timeline is rebuilt or the session writes. - publishRepairEpoch: () => - collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence), adopt: host.adopt, - // Repair and file-format notices are about the conversation, not any turn in it. + // File-format and roster notices are about the conversation, not any turn in it. appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), agent: host.identity.agent, - highestFence: () => host.state().highestFence, - malformedRows: host.malformedRows, - setMalformedRows: host.setMalformedRows, - readOnly: host.readOnly - }) -} - -/** - * A newer Orca's database: each chat shows what this build can read of it, from the database or a - * per-chat file never copied in, and nothing is written, copied, repaired or founded. Every write - * the store is asked for is refused as read-only. - */ -async function restoreFromNewerDatabase( - host: JournalStoreHost, - source: Parameters[0] -): Promise { - const { sessionId } = host.identity - let loaded: JournalLoad | null - try { - loaded = - (await previewPerSessionJournal(source)) ?? replayJournal(source.database.db, sessionId) - } catch (error) { - // Tables the newer schema changed: still a chat only an update opens, not a damaged one. - throw new AgentSessionJournalError( - 'journal_read_only', - `agent-session journal for ${sessionId} could not be read under a newer schema`, - { cause: error } - ) - } - host.setOpenedCorrupt(false) - host.adopt({ - state: loaded?.state ?? createJournalReducerState(sessionId, ''), - readOnly: true, - corrupt: false, - malformedRows: 0 + highestFence: () => host.state().highestFence }) } diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index d7c5e521c821..18cb7efa47a8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -18,14 +18,14 @@ import type { } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalRepairDisclosure } from './journal-repair-disclosure' import { journalDatabasePath } from './journal-host-database' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener, openTestJournalHostDatabase, liveTestJournalRows, - insertTestJournalRowJson + insertTestJournalRowJson, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -110,10 +110,9 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -const REPAIR_TEXT = journalRepairDisclosure().body.text describe('axis 1: the database shape', () => { - // A newer build's database opens read-only: its chats read, and nothing here writes to it. - it('reads a database a newer build stamped, refuses every write, and writes nothing', async () => { + // A newer build's database: none of its chats open here, and nothing here writes to it. + it('refuses every chat in a database a newer build stamped, and writes nothing', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -123,32 +122,17 @@ describe('axis 1: the database shape', () => { await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) const before = await digest(journalDatabasePath(root)) - const reopened = await open() - expect(reopened.isReadOnly).toBe(true) - expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('a')]) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + // A chat the database never held is refused the same way rather than being founded. await expect( - reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).rejects.toMatchObject({ - code: 'journal_read_only' - }) - // A chat the database never held opens empty rather than being founded, and refuses the same way. - const unwritten = await journals.open({ - identity: { ...IDENTITY, sessionId: 'session-2' }, - stateDirectory: root - }) - expect(unwritten.isReadOnly).toBe(true) - expect(unwritten.snapshot().items).toEqual([]) - await expect( - unwritten.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).rejects.toMatchObject({ - code: 'journal_read_only' - }) + journals.open({ identity: { ...IDENTITY, sessionId: 'session-2' }, stateDirectory: root }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) await journals.closeAll() expect(await digest(journalDatabasePath(root))).toBe(before) }) // Tables a newer schema changed read as a chat only an update opens, not as damage. - it('refuses as read-only a chat whose tables a newer build changed', async () => { + it("refuses as a newer Orca's a chat whose tables a newer build changed", async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -160,23 +144,7 @@ describe('axis 1: the database shape', () => { db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) }) - await expect(open()).rejects.toMatchObject({ code: 'journal_read_only' }) - }) - - it('refuses the schema escape hatch on a store latched by a newer row', async () => { - const journal = await open() - const epoch = journal.epoch - const nextSeq = journal.cursor().sequence + 1 - await journal.close() - await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) - - const reopened = await open() - // With byte-copy quarantine gone there is nothing for `schema_unreadable` to - // do differently, so it takes the same writable guard as every other reason. - await expect(reopened.rollEpoch('schema_unreadable', 2)).rejects.toMatchObject({ - code: 'journal_read_only' - }) - expect(reopened.isReadOnly).toBe(true) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) }) // The only older version brought forward; 1 and 2 are refused (journal-database.test.ts). @@ -190,7 +158,6 @@ describe('axis 1: the database shape', () => { await withDatabase((db) => db.pragma('user_version = 0')) const reopened = await open() - expect(reopened.isReadOnly).toBe(false) expect(reopened.snapshot().items).toHaveLength(1) await reopened.close() await withDatabase((db) => { @@ -200,7 +167,7 @@ describe('axis 1: the database shape', () => { }) describe('axis 2: the row body shape', () => { - it('degrades to read-only on a row from a newer build, without skipping it', async () => { + it("refuses a journal holding a row from a newer build as a newer Orca's, and keeps that row", async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -211,14 +178,7 @@ describe('axis 2: the row body shape', () => { await journal.close() await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) - const reopened = await open() - expect(reopened.isReadOnly).toBe(true) - await expect( - reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).rejects.toMatchObject({ - code: 'journal_read_only' - }) - await reopened.close() + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) // Never skipped, never deleted: the row this build cannot read is still there. await withDatabase((db) => { const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === nextSeq) @@ -226,7 +186,7 @@ describe('axis 2: the row body shape', () => { }) }) - it('skips a malformed row without giving up the journal, and discloses the skip', async () => { + it('refuses a journal holding a row that is not a row, and keeps that row', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -237,35 +197,16 @@ describe('axis 2: the row body shape', () => { await journal.close() await appendRawRow(epoch, nextSeq, '{not json') - const reopened = await open() - expect(reopened.isReadOnly).toBe(false) - const items = reopened.snapshot().items - // The surviving row is untouched… - expect(items.some((entry) => entry.body.kind === 'message')).toBe(true) - // …and the skip is visible in the timeline instead of silently swallowed. - expect( - items.some((entry) => entry.body.kind === 'status' && entry.body.text === REPAIR_TEXT) - ).toBe(true) - }) - - it('keeps one disclosure row across reopens instead of stacking duplicates', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(open()).rejects.toMatchObject({ + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } + }) + } + await withDatabase((db) => { + const stored = liveTestJournalRows(db, IDENTITY.sessionId) + expect(stored.map((row) => row.seq)).toEqual([1, 2, nextSeq]) + expect(stored.at(-1)?.rowJson).toBe('{not json') }) - const epoch = journal.epoch - const nextSeq = journal.cursor().sequence + 1 - await journal.close() - await appendRawRow(epoch, nextSeq, '{not json') - - await open().then((first) => first.close()) - const reopened = await open() - expect( - reopened - .snapshot() - .items.filter((entry) => entry.body.kind === 'status' && entry.body.text === REPAIR_TEXT) - ).toHaveLength(1) }) it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { @@ -296,7 +237,6 @@ describe('axis 2: the row body shape', () => { ) const reopened = await open() - expect(reopened.isReadOnly).toBe(false) expect(reopened.snapshot().items.some((entry) => entry.itemId === '%')).toBe(true) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 871aca7880dc..fcb541b8faa8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -294,7 +294,7 @@ describe('replay', () => { expect(reopened.snapshot().items).toHaveLength(0) }) - it('keeps the intact prefix and drops the rejected suffix', async () => { + it('refuses a journal with a gap and keeps every row', async () => { const journal = await open() for (let index = 0; index < 4; index += 1) { await journal.appendItem(item(index), body(`m${index}`), { @@ -302,22 +302,18 @@ describe('replay', () => { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } - const before = journal.epoch await journal.close() await withJournalDatabase(root, (db) => { deleteTestJournalRow(db, IDENTITY.sessionId, 3) }) - const reopened = await open() - expect(reopened.epoch).toBe(before) - expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0')]) - // Sequences 4 and 5 are VALID rows that the gap at 3 made unreplayable. - // Nothing preserves them; recovery rebuilds the epoch from provider history. + await expect(open()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) await withJournalDatabase(root, (db) => { const rows = liveTestJournalRows(db, IDENTITY.sessionId) - expect(rows.map((row) => row.seq)).toEqual([1, 2]) + expect(rows.map((row) => row.seq)).toEqual([1, 2, 4, 5]) }) - expect(reopened.repair).toEqual({ malformedRows: 0 }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 2349524ccfe9..e61a54eba6e2 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -67,7 +67,6 @@ import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-wr import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' -import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender, JournalResolvedItem } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalStopMarks } from './journal-stop-marks' @@ -81,9 +80,6 @@ export class AgentSessionJournal { private readonly mintEpoch: () => string private state: JournalReducerState - private readOnly = false - private malformedRows = 0 - private openedCorrupt = false private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue @@ -114,10 +110,8 @@ export class AgentSessionJournal { owe: (work) => this.queue.owe(work), database: () => this.database, state: () => this.state, - readOnly: () => this.readOnly, - setReadOnly: (readOnly) => { - this.readOnly = readOnly - }, + // A newer Orca's database: nothing it holds opens, and every write is refused. + readOnly: () => this.database.readOnly, cursor: this.cursor, adopt: (loaded) => { this.adoptLoadedJournal(loaded) @@ -127,14 +121,7 @@ export class AgentSessionJournal { applyJournalRow(this.state, row) this.onCommitted?.() }, - setOpenedCorrupt: (corrupt) => { - this.openedCorrupt = corrupt - }, notifyCommitted: () => this.onCommitted?.(), - malformedRows: () => this.malformedRows, - setMalformedRows: (count) => { - this.malformedRows = count - }, journal: () => this, enqueue: (build) => this.rowWriter.enqueue(build) }) @@ -147,10 +134,6 @@ export class AgentSessionJournal { this.restore = collaborators.restore } - get isReadOnly(): boolean { - return this.readOnly - } - get epoch(): string { return this.state.epoch } @@ -165,16 +148,6 @@ export class AgentSessionJournal { ) } - /** What the last open's repair did. */ - get repair(): { malformedRows: number } { - return { malformedRows: this.malformedRows } - } - - /** The open replayed an unusable prefix: the chat is owed a rebuild from provider history. */ - get needsRebuild(): boolean { - return this.openedCorrupt - } - async open(): Promise { await this.restore() this.openedThrough = this.cursor() @@ -285,8 +258,7 @@ export class AgentSessionJournal { readSince(cursor: AgentJournalCursor, limit?: number): JournalReadSince { const { sessionId } = this.identity const rowsAfter = journalRowsAfterReader(this.database.db, sessionId, this.state.epoch, limit) - const source = { state: this.state, rowsAfter, readOnly: this.readOnly } - return readJournalSince(source, cursor, () => this.cursor()) + return readJournalSince({ state: this.state, rowsAfter }, cursor, () => this.cursor()) } /** Upsert by stable identity. The revision is assigned here so a caller @@ -384,8 +356,8 @@ export class AgentSessionJournal { return rejectJournalQueuedSubmissions(this, fence, rejection, which) } - /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, - * and an unreadable schema. It invalidates every cursor; clients reload. */ + /** The escape hatch for a forked handle and an unreadable schema. It invalidates every cursor; + * clients reload. */ async rollEpoch(reason: AgentJournalEpochReason, fence: number): Promise { return this.epochController.roll(reason, fence) } @@ -399,6 +371,6 @@ export class AgentSessionJournal { } private adoptLoadedJournal(loaded: JournalLoad): void { - Object.assign(this, journalStoreLoadedFields(loaded)) + this.state = loaded.state } } diff --git a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts index c000ffe9fbae..86ff11c54a3b 100644 --- a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts @@ -117,40 +117,40 @@ describe('streaming journal replay', () => { expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0]) }) - it('keeps the prefix but latches read-only for a future row beyond a gap', () => { + it('reads past a gap to a future row, which names the newer row and no damage', () => { put(anchor()) put(revision(2)) put(revision(4)) put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 }) const loaded = replayJournal(opened.db, sessionId)! - expect(loaded).toMatchObject({ readOnly: true, corrupt: true, malformedRows: 0 }) - expect(loaded.truncateFrom).toBeUndefined() + expect(loaded).toMatchObject({ newer: { sequence: 5 }, damage: null }) expect(loaded.state.items.get('message-1')?.revision).toBe(2) expect(loaded.state.lastSequence).toBe(2) const checkpoint = opened.db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[] expect(checkpoint[0].busy).toBe(0) }) - it('keeps gap repair precedence when a later row is malformed', () => { + it('names the first damage when a later row is malformed too', () => { put(anchor()) put(revision(2)) put(revision(4)) insertTestJournalRowJson(opened.db, sessionId, 5, '{') const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ - readOnly: false, - corrupt: true, - malformedRows: 1, - truncateFrom: 4 + newer: null, + damage: { sequence: 3, cause: 'sequence-gap' } }) expect(loaded.state.lastSequence).toBe(2) }) - it('rejects an unanchored prefix before a later gap', () => { + it('names a missing epoch row before a later gap', () => { put(revision(1)) put(revision(3)) const loaded = replayJournal(opened.db, sessionId)! - expect(loaded).toMatchObject({ readOnly: false, corrupt: true, truncateFrom: 1 }) + expect(loaded).toMatchObject({ + newer: null, + damage: { sequence: 1, cause: 'no-epoch-row' } + }) expect(loaded.state.items.size).toBe(0) expect(loaded.state.lastSequence).toBe(0) }) diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts new file mode 100644 index 000000000000..e56bcc92c962 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts @@ -0,0 +1,271 @@ +// Inside a row of a known kind, what a newer build wrote is never damage. A body of a kind this +// build does not know (or a plan subject of one) is kept and the chat stays writable; a mutation or +// sent message of a newer kind fails the load as a newer Orca's chat with every row kept as it was, +// even beside damage. Anything else that fails is damage: the chat fails to load, every row kept. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-content', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-content-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) +} + +/** An anchor, two items, then `rows` as a newer build (or damage) wrote them, then one more item + * of this build's: closed, so the next open replays all of it. */ +async function journalWith(rows: (epoch: string) => Record[]) { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'one' }, SCOPE) + await first.appendItem(item(1), { kind: 'status', text: 'two' }, SCOPE) + const epoch = first.epoch + let seq = first.cursor().sequence + await journals.closeAll() + const tail = itemRow(epoch, 'codex:thread-1:turn-1:9', { kind: 'status', text: 'after' }) + const { db } = openTestJournalHostDatabase(root) + for (const row of [...rows(epoch), tail]) { + seq += 1 + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, JSON.stringify({ ...row, seq }), 5_000) + } + closeTestJournalHostDatabase(root) + return { written: stored() } +} + +function itemRow(epoch: string, itemId: string, body: Record) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'item', + itemId, + revision: 1, + body + } +} + +function batchRow(epoch: string, mutations: Record[]) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations + } +} + +const NEWER_BODY = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } +const PLAN_APPROVAL = { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, + subject: { kind: 'plan', text: 'Step one' } +} + +/** Refused as a newer Orca's chat on every open, with every row left as it was. */ +async function expectNewerAndKept(written: { seq: number; rowJson: string }[]) { + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(stored()).toEqual(written) +} + +/** Opens writable, keeps the newer item in the snapshot, takes a write, and leaves every earlier + * row byte-identical. */ +async function expectWritableAndKept(written: { seq: number; rowJson: string }[], itemId: string) { + const journal = await open() + expect(journal.snapshot().items.some((entry) => entry.itemId === itemId)).toBe(true) + await journal.appendItem(item(5), { kind: 'status', text: 'taken' }, SCOPE) + await journals.closeAll() + expect(stored().slice(0, written.length)).toEqual(written) +} + +describe("a newer build's body kind, kept and writable", () => { + it.each([ + ['an item body of a newer kind', NEWER_BODY], + ['a plan subject of a newer kind', { ...PLAN_APPROVAL, subject: { kind: 'diff' } }] + ])('keeps %s and stays writable', async (_name, body) => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', body) + ]) + await expectWritableAndKept(written, 'codex:thread-1:turn-1:2') + }) + + it('keeps a lifecycle mutation whose body is a newer kind, and stays writable', async () => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [ + { kind: 'item', itemId: 'codex:thread-1:turn-1:4', revision: 1, body: NEWER_BODY } + ]) + ]) + await expectWritableAndKept(written, 'codex:thread-1:turn-1:4') + }) +}) + +describe("a newer build's row content that stays a closed set", () => { + it.each([['of a newer kind', { kind: 'pin', itemId: 'codex:thread-1:turn-1:3', revision: 2 }]])( + 'fails the load on a lifecycle mutation %s', + async (_name, mutation) => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [ + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { kind: 'status', text: 'ok' } + }, + mutation + ]) + ]) + await expectNewerAndKept(written) + } + ) + + it('fails the load on a submission whose body is a newer kind', async () => { + const { written } = await journalWith((epoch) => [ + { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'submission', + clientMessageId: 'client-1', + payloadFingerprint: 'f', + providerHandle: IDENTITY.providerHandle, + body: { kind: 'voice-note', clip: 'by a newer build' } + } + ]) + await expectNewerAndKept(written) + }) + + it('fails the load on a newer mutation kind that carries no item id', async () => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [{ kind: 'turn-settle', turnId: 'turn-1', outcome: 'done' }]) + ]) + await expectNewerAndKept(written) + }) + + it.each([ + [ + 'a row', + (epoch: string) => ({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'submission', + clientMessageId: 'client-1', + payloadFingerprint: 7, + providerHandle: IDENTITY.providerHandle, + body: { kind: 'voice-note', clip: 'by a newer build' } + }) + ], + [ + 'a batch', + (epoch: string) => + batchRow(epoch, [ + { kind: 'item', itemId: 'codex:thread-1:turn-1:3', revision: 1, body: { kind: 'diff' } }, + { kind: 'pin' } + ]) + ] + ])('fails the load as newer when damage sits beside newer content in %s', async (_where, row) => { + const { written } = await journalWith((epoch) => [row(epoch)]) + await expectNewerAndKept(written) + }) +}) + +describe("a turn's context usage of a newer shape", () => { + it('is dropped like an unusable annotation: the chat stays writable and keeps the row', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + contextUsage: { used: { kind: 'measured', capturedAt: 1 } } + }) + ]) + const journal = await open() + expect(journal.snapshot().items[2]?.body).toEqual({ + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + await journals.closeAll() + expect(stored()).toEqual(written) + }) +}) + +describe('damage', () => { + it.each([ + ['a broken required field', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], + [ + 'a broken optional value', + { kind: 'turn', turnId: 'turn-1', state: 'done', durationMs: null } + ], + [ + 'a turn lifecycle whose turn is a number', + { kind: 'status', text: 'Turn started', turnLifecycle: { turnId: 7, state: 'running' } } + ], + ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }] + ])('fails the load from %s, and every row is kept', async (_name, body) => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', body) + ]) + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(open()).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + }) + } + expect(stored()).toEqual(written) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts index 794ca52ab7d5..bb965817b595 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -1,6 +1,7 @@ -// A newer build's row kind is never repaired away: like a newer row version, it latches this build -// read-only with every row kept, so the newer build still reads the whole chat after an upgrade. -// A row whose stored sequence and body disagree is damage at its stored sequence. +// A newer build's row kind is never damage: like a newer row version, it fails this build's load as +// a newer Orca's chat with every row kept, so the newer build still reads the whole chat after an +// upgrade. +// A row whose stored sequence and body disagree is damage, which fails the load with every row kept. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -17,10 +18,15 @@ import { createTrackedJournalOpener, insertTestJournalRowJson, liveTestJournalRows, - openTestJournalHostDatabase + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' import { parseJournalRow, type JournalRow } from './journal-row-schema' +const UNLOADABLE = { + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } +} + const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-newer-kind', workspaceId: 'ws-1', @@ -70,7 +76,8 @@ function open() { /** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and * reopens it as a restarted host would. */ -async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { +/** Rewrites rows with every journal closed, so the caller's next open replays them. */ +async function rewriteClosed(edit: (put: (seq: number, json: string) => void) => void = () => {}) { await journals.closeAll() const { db } = openTestJournalHostDatabase(root) edit((seq, json) => { @@ -81,6 +88,10 @@ async function restartAfter(edit: (put: (seq: number, json: string) => void) => insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq) }) closeTestJournalHostDatabase(root) +} + +async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { + await rewriteClosed(edit) return open() } @@ -167,6 +178,7 @@ describe('parsing a row of a kind this build does not know', () => { ['a string timestamp', { ts: '5007' }], ['an empty epoch', { epoch: '' }], ['an empty kind', { kind: '' }], + ['a kind of only whitespace', { kind: ' \t' }], ['a kind that is not a string', { kind: 7 }] ])('reads one with %s as malformed', (_name, broken) => { const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken))) @@ -189,7 +201,7 @@ describe('parsing a row of a kind this build does not know', () => { }) describe("a journal holding a newer build's row kind", () => { - it('opens read-only with the rows before it, refuses writes, keeps every row, and reads whole once the kind is known', async () => { + it("fails the load as a newer Orca's chat, keeps every row, and reads whole once the kind is known", async () => { const first = await open() for (const ordinal of [0, 1, 2, 3]) { await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE) @@ -199,32 +211,24 @@ describe("a journal holding a newer build's row kind", () => { const known = stored().find((row) => row.seq === 3)?.rowJson ?? '' const newer = JSON.stringify(newerRow(epoch, 3)) - const journal = await restartAfter((put) => put(3, newer)) - const latched = stored() - expect(journal.isReadOnly).toBe(true) - expect(journal.repair).toEqual({ malformedRows: 0 }) - expect(itemIds(journal)).toEqual(['codex:thread-1:turn-1:0']) - await expect( - journal.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) - ).rejects.toMatchObject({ code: 'journal_read_only' }) - - const reopened = await restartAfter() - expect(reopened.isReadOnly).toBe(true) - expect(stored()).toEqual(latched) + await rewriteClosed((put) => put(3, newer)) + const refused = stored() + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(stored()).toEqual(refused) expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer) // Stand-in for the newer build after an upgrade: the same place holds a kind it reads. const upgraded = await restartAfter((put) => put(3, known)) - expect(upgraded.isReadOnly).toBe(false) expect(itemIds(upgraded)).toHaveLength(4) await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) expect(upgraded.cursor().sequence).toBe(6) }) - it('stays read-only with nothing deleted when the row names another sequence than its key', async () => { + it("fails the load as a newer Orca's chat with nothing deleted when the row names another sequence than its key", async () => { const epoch = await journalWithOneItem() - const journal = await restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) - expect(journal.isReadOnly).toBe(true) + await rewriteClosed((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) expect(storedKinds()).toEqual([ [1, 'epoch'], [2, 'item'], @@ -232,18 +236,15 @@ describe("a journal holding a newer build's row kind", () => { ]) }) - it('reads a newer kind whose envelope is broken as malformed and drops from it', async () => { + it('reads a newer kind whose envelope is broken as damage, and keeps it', async () => { const epoch = await journalWithOneItem() - const journal = await restartAfter((put) => - put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' }))) - ) - expect(journal.isReadOnly).toBe(false) - expect(journal.repair).toEqual({ malformedRows: 1 }) - // 3 is the disclosure the repair appends where the broken row was. + await expect( + restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' })))) + ).rejects.toMatchObject(UNLOADABLE) expect(storedKinds()).toEqual([ [1, 'epoch'], [2, 'item'], - [3, 'item'] + [3, 'future-mark'] ]) }) }) @@ -252,25 +253,16 @@ describe('a row whose body names another sequence than its stored key', () => { it.each([ ['an earlier', 2], ['a later', 9] - ])( - 'is dropped at its key when it names %s one, and the next write succeeds', - async (_name, seq) => { - const epoch = await journalWithOneItem() - const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } - const journal = await restartAfter((put) => put(3, JSON.stringify(tombstone))) - expect(journal.repair).toEqual({ malformedRows: 1 }) - expect(journal.needsRebuild).toBe(true) - // The valid row at 2 stays; 3 is the disclosure the repair appends. - expect(storedKinds()).toEqual([ - [1, 'epoch'], - [2, 'item'], - [3, 'item'] - ]) - - await journal.appendItem(item(1), { kind: 'status', text: 'after' }, SCOPE) - const reopened = await restartAfter() - expect(reopened.cursor().sequence).toBe(4) - expect(itemIds(reopened)).toHaveLength(3) - } - ) + ])('fails the load and is kept when it names %s one', async (_name, seq) => { + const epoch = await journalWithOneItem() + const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } + await expect(restartAfter((put) => put(3, JSON.stringify(tombstone)))).rejects.toMatchObject( + UNLOADABLE + ) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'tombstone'] + ]) + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts index 10e709d174f7..8da990a4eac7 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -9,7 +9,8 @@ export class AgentSessionJournalError extends Error { | 'journal_read_only' | 'journal_stale_fence' | 'journal_closed' - | 'journal_submission_exists', + | 'journal_submission_exists' + | 'journal_row_rejected', message: string, options?: ErrorOptions ) { @@ -18,8 +19,8 @@ export class AgentSessionJournalError extends Error { } } -/** A journal written by a newer schema is readable but never writable: this - * host cannot represent rows it does not understand. */ +/** A newer Orca's database is never written: this host cannot represent rows it does not + * understand. */ export function assertJournalWritable(readOnly: boolean, sessionId: string): void { if (readOnly) { throw new AgentSessionJournalError( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 638a62bdf02a..15911b5e080c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -136,7 +136,6 @@ describe('draft bookkeeping inside a journal append', () => { PRIMARY KEY (session_id, message_id))`) db.close() const journal = await open() - expect(journal.isReadOnly).toBe(false) await queueAndConsume(journal, 'draft-1') await journal.resolveDispatch({ clientMessageId: 'sub-draft-1', diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index c3910e466854..6c609853d690 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -125,7 +125,7 @@ describe('draft rows', () => { try { const version = Number(db.pragma('user_version', { simple: true })) // An old build compares stored == supported and keeps writing; a bump - // would latch it read-only after downgrade. + // would cost it every chat after a downgrade. expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) const table = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") @@ -150,7 +150,6 @@ describe('draft rows', () => { db.exec('DROP TABLE queued_messages') db.close() const journal = await open() - expect(journal.isReadOnly).toBe(false) const row = await queueDraft(journal, 'draft-1') expect(row.position).toBe(1) }) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index b780e3d11543..ba9ee8a54533 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -80,9 +80,6 @@ export function readAgentSessionHistory( snapshot: AgentJournalSnapshot = journal.snapshot(), scope: AgentSessionHistoryScope = 'every-agent' ): AgentSessionHistoryResult { - if (journal.isReadOnly) { - return historyReset(snapshot, 'schema_unreadable') - } const limit = resolveHistoryLimit(request.limit) if (request.direction === 'after') { return readForward(journal, snapshot, request.cursor, limit) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts deleted file mode 100644 index 52fc3c86b9b6..000000000000 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Recovery drives the real journal loader against real on-disk damage: a hole -// punched in the row sequence, and a row stamped with a schema this host cannot -// read — on both version axes, because only one of them is detectable before a -// read. - -import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' -import { journalDatabasePath } from '../agent-session-journal/journal-host-database' -import { replayJournal } from '../agent-session-journal/journal-open' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener, - openTestJournalHostDatabase, - loadTestJournal, - readTestJournalRows, - deleteTestJournalRow, - insertTestJournalRowJson, - updateTestJournalRowJson -} from '../agent-session-journal/journal-host-database-test-support' -import type Database from '../../sqlite/sync-database' -import { - openAgentSessionJournalWithRecovery, - providerHistoryId -} from './agent-session-journal-recovery' -import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' -import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' - -// The store's replay goes through the mock: it is the only read of the journal an open makes. -vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { - const actual = await importOriginal<{ replayJournal: typeof replayJournal }>() - return { ...actual, replayJournal: vi.fn(actual.replayJournal) } -}) - -const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: CODEX_SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: CODEX_SESSION } -} - -const CODEX_LINES = [ - { - type: 'session_meta', - timestamp: '2026-08-05T10:00:00.000Z', - payload: { - id: CODEX_SESSION, - session_id: CODEX_SESSION, - cwd: '/Users/dev/project', - originator: 'codex_cli_rs', - cli_version: '0.146.1' - } - }, - { - type: 'event_msg', - timestamp: '2026-08-05T10:00:02.000Z', - payload: { type: 'user_message', message: 'add a retry', kind: 'plain' } - }, - { - type: 'event_msg', - timestamp: '2026-08-05T10:00:05.000Z', - payload: { type: 'agent_message', message: 'On it.' } - } -] - -let root: string -let journalDir: string -let historyFilePath: string -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal } -} - -/** Fills a journal with `count` items and hands back its epoch. */ -async function seedJournal(count: number): Promise { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) - for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem( - item(ordinal), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const epoch = journal.epoch - await journal.close() - return epoch -} - -/** A journal whose epoch row is gone: every surviving row is unanchored, so a - * repair has to set aside the whole range. */ -async function seedRepairableSession(): Promise { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) - await journal.appendSubmission({ - clientMessageId: 'client-message-1', - payloadFingerprint: 'fingerprint-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-message-1', - state: 'accepted', - providerIdentity: item(1), - fence: 1 - }) - await journal.close() - await deleteRow(1) -} - -async function withJournalDatabase( - directory: string, - run: (db: Database.Database) => void -): Promise { - run(openTestJournalHostDatabase(directory).db) -} - -async function digest(path: string): Promise { - return createHash('sha256') - .update(await readFile(path)) - .digest('hex') -} - -/** The same logical hole `findSequenceGap` detects at replay. */ -async function deleteRow(seq: number): Promise { - await withJournalDatabase(journalDir, (db) => { - deleteTestJournalRow(db, CODEX_SESSION, seq) - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-wire-recovery-')) - journalDir = join(root, 'journal') - historyFilePath = join(root, 'rollout.jsonl') - await writeFile( - historyFilePath, - `${CODEX_LINES.map((line) => JSON.stringify(line)).join('\n')}\n`, - 'utf-8' - ) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('providerHistoryId', () => { - it('uses the provider handle, never the Orca session id', () => { - expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9') - expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe( - 'sess-9' - ) - }) -}) - -describe('openAgentSessionJournalWithRecovery', () => { - it('opens a healthy journal untouched', async () => { - await seedJournal(2) - const opened = journals.track( - await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }).then((result) => result.journal) - ) - expect(opened.snapshot().items).toHaveLength(2) - }) - - it("reads the journal once: the store's replay is the open", async () => { - await seedJournal(2) - vi.mocked(replayJournal).mockClear() - const opened = journals.track( - await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }).then((result) => result.journal) - ) - expect(opened.snapshot().items).toHaveLength(2) - expect(replayJournal).toHaveBeenCalledOnce() - }) - - it('rebuilds a holed journal in place on a fresh epoch', async () => { - await seedJournal(3) - await deleteRow(3) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) - expect(opened.recovery?.imported).toBeGreaterThan(0) - expect(opened.journal.isReadOnly).toBe(false) - // The rebuilt timeline is the only content of its epoch — nothing from the - // damaged prefix survives into it. - const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) - expect(texts.some((text) => text.includes('item-1'))).toBe(false) - expect(texts.some((text) => text.includes('add a retry'))).toBe(true) - }) - - // A newer build's row is never rewritten under it, and there is no sibling to write instead: - // the chat reads what it can and refuses writes. - it('opens a future row-body version read-only and leaves it exactly as found', async () => { - const epoch = await seedJournal(1) - await withJournalDatabase(journalDir, (db) => { - insertTestJournalRowJson( - db, - CODEX_SESSION, - 3, - JSON.stringify({ v: 99, seq: 3, epoch, kind: 'item', fence: 1, ts: 1 }) - ) - }) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - - expect(opened.recovery).toBeNull() - expect(opened.journal.isReadOnly).toBe(true) - // A send says to update, as a database a newer Orca wrote does. - const sent = await performSend( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. - { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, - { - clientMessageId: 'client-after-downgrade', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - } - ) - expect(sent).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalWrittenByNewerOrca' }, - message: JOURNAL_NEWER_SCHEMA_MESSAGE - } - }) - await withJournalDatabase(journalDir, (db) => { - const rows = readTestJournalRows(db, CODEX_SESSION, epoch) - expect(rows.some((entry) => entry.rowJson.includes('"v":99'))).toBe(true) - expect(rows).toHaveLength(3) - }) - }) - - // A downgrade: the database a newer Orca stamped opens read-only. The chat shows its history, a - // send says to update, and the file is left byte-identical. - it('shows a chat from a database a newer Orca stamped, and a send says to update', async () => { - await seedJournal(2) - await withJournalDatabase(journalDir, (db) => { - db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - }) - closeTestJournalHostDatabases() - const path = journalDatabasePath(journalDir) - const before = await digest(path) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - - expect(opened.recovery).toBeNull() - expect(opened.journal.isReadOnly).toBe(true) - const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) - expect(texts.filter((text) => /item-[12]/.test(text))).toHaveLength(2) - const sent = await performSend( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. - { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, - { - clientMessageId: 'client-after-downgrade', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - } - ) - expect(sent).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalWrittenByNewerOrca' }, - message: JOURNAL_NEWER_SCHEMA_MESSAGE - } - }) - await journals.closeAll() - expect(await digest(path)).toBe(before) - }) - - // T-logical-per-chat: one file holds every chat, and damage one chat's replay finds is still - // that chat's alone. Only it rebuilds; a neighbour's rows and epoch are not touched. - it('rebuilds only the damaged chat and leaves another chat in the same file untouched', async () => { - const neighbour: AgentSessionJournalIdentity = { - ...IDENTITY, - sessionId: 'neighbour-session', - providerHandle: { kind: 'codex', threadId: 'neighbour-thread' } - } - const other = await journals.open({ identity: neighbour, stateDirectory: journalDir }) - for (let ordinal = 1; ordinal <= 3; ordinal += 1) { - await other.appendItem( - item(ordinal), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `n-${ordinal}` }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const neighbourEpoch = other.epoch - await other.close() - const database = openTestJournalHostDatabase(journalDir) - const neighbourBefore = readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch) - await seedJournal(3) - await withJournalDatabase(journalDir, (db) => { - updateTestJournalRowJson(db, CODEX_SESSION, 3, '}{') - }) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database, - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - - expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) - expect(opened.recovery?.imported).toBeGreaterThan(0) - const reopened = await journals.open({ identity: neighbour, stateDirectory: journalDir }) - expect(reopened.epoch).toBe(neighbourEpoch) - expect(readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual( - neighbourBefore - ) - }) - - // The rehydrate deletes every live row to publish its replacement epoch, so - // everything replay rejected is gone for good by the time the import runs. - // Orca minted the submission, receipt and lifecycle identities; no provider - // transcript can hand them back. - it('rebuilds from provider history when the epoch row itself is gone', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) - await journal.appendSubmission({ - clientMessageId: 'client-message-1', - payloadFingerprint: 'fingerprint-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-message-1', - state: 'accepted', - providerIdentity: item(1), - fence: 1 - }) - await journal.appendLifecycleBatch({ - settlementId: 'settlement-1', - fence: 1, - mutations: [ - { - kind: 'item', - identity: { provider: 'orca', clientMessageId: 'approval-1' }, - body: { kind: 'status', text: 'approved' }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - }) - await journal.close() - // Sequence 1 is the epoch row: everything behind it is valid but unanchored. - await deleteRow(1) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt' }) - expect(opened.recovery?.imported).toBeGreaterThan(0) - expect(JSON.stringify(opened.journal.snapshot().items.map((entry) => entry.body))).toContain( - 'add a retry' - ) - }) - - it('still opens the session when provider history cannot be read', async () => { - await seedJournal(3) - await deleteRow(3) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: join(root, 'missing.jsonl') - }) - journals.track(opened.journal) - expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) - expect(opened.recovery?.error).toBeTruthy() - // A missing provider transcript must not clear the intact journal prefix. - expect(opened.journal.snapshot().items.map((entry) => entry.body.kind)).toEqual(['message']) - }) - - // A repair that KEEPS a prefix has no emptied epoch to anchor, so nothing - // about the surviving rows records that the deleted suffix was never rebuilt. - // Unmarked, the next probe reads a contiguous anchored prefix, calls it clean, - // and the dropped stretch of timeline is gone for good. - it('keeps a partially repaired journal corrupt until provider history replaces it', async () => { - await seedJournal(3) - await deleteRow(3) - const empty = join(root, 'empty.jsonl') - await writeFile(empty, '', 'utf-8') - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: empty - }) - journals.track(first.journal) - expect(first.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) - expect(first.recovery?.error).toBeTruthy() - // Only the unanchored suffix went; the prefix the repair kept is still live. - expect(first.journal.snapshot().items.map((entry) => entry.body.kind)).toEqual(['message']) - await first.journal.close() - - // The deletion is durable, so the demand for a rebuild has to be too. - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) - - // A readable transcript rebuilds the epoch, and THAT is what retires it. - const retried = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(retried.journal) - expect(retried.recovery?.imported).toBeGreaterThan(0) - await retried.journal.close() - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: false }) - }) - - // The reproduced path. Deleting sequence 1 leaves every surviving row - // unanchored, so the repair drops ALL of them — and provider history is not - // there to publish a replacement. A journal in that state used to reopen as - // clean: an append took sequence 1 as an ordinary row, replay accepted it, - // and recovery never asked the provider for the timeline again. - it('does not normalize an epoch a repair emptied while provider history was unavailable', async () => { - await seedRepairableSession() - const missing = join(root, 'missing.jsonl') - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: missing - }) - journals.track(first.journal) - expect(first.recovery?.error).toBeTruthy() - expect(first.recovery?.imported).toBe(0) - await first.journal.close() - - // Reopen: the epoch still holds nothing but the repair, so recovery runs again. - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) - const reopened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: missing - }) - journals.track(reopened.journal) - expect(reopened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) - // Nothing but the anchor: the repair rebuilt no history of its own. - expect(reopened.journal.snapshot().items).toEqual([]) - - // The append lands ABOVE the epoch anchor, never on top of it. - await reopened.journal.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'typed later' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const epoch = reopened.journal.epoch - await reopened.journal.close() - await withJournalDatabase(journalDir, (db) => { - const rows = readTestJournalRows(db, CODEX_SESSION, epoch) - expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ kind: 'epoch', seq: 1 }) - }) - }) - - // An empty transcript is a plausible transient provider state, and it used to - // end recovery for good: the import published an empty replacement epoch that - // deleted the repair's anchor, the next probe called that clean, and the user's - // timeline was never rebuilt. - it('does not retire the repair marker when provider history exists but holds no messages', async () => { - await seedRepairableSession() - const empty = join(root, 'empty.jsonl') - await writeFile(empty, '', 'utf-8') - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: empty - }) - journals.track(first.journal) - expect(first.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) - expect(first.recovery?.error).toBeTruthy() - // The anchor the repair published is still the epoch; the empty import did - // not replace it with a clean one. - expect(first.journal.snapshot().items).toEqual([]) - const epoch = first.journal.epoch - await first.journal.close() - await withJournalDatabase(journalDir, (db) => { - const rows = readTestJournalRows(db, CODEX_SESSION, epoch) - expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ - kind: 'epoch', - seq: 1, - reason: 'unreconcilable_prefix' - }) - }) - - // The session still reports corrupt, so the next attach retries. - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) - - // And a transcript that DOES have content still rebuilds the timeline. - const retried = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(retried.journal) - expect(retried.recovery?.imported).toBeGreaterThan(0) - expect(JSON.stringify(retried.journal.snapshot().items.map((entry) => entry.body))).toContain( - 'add a retry' - ) - }) - - it('rebuilds the emptied epoch once provider history is readable again', async () => { - await seedRepairableSession() - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: join(root, 'missing.jsonl') - }) - journals.track(first.journal) - await first.journal.close() - - const retried = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(retried.journal) - expect(retried.recovery?.imported).toBeGreaterThan(0) - expect(JSON.stringify(retried.journal.snapshot().items.map((entry) => entry.body))).toContain( - 'add a retry' - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts deleted file mode 100644 index 42da9a986dad..000000000000 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Journal recovery: rehydrate a chat's timeline from provider history. -// -// One trigger: a chat whose replayed prefix is unusable (a gap, an unanchored prefix, a malformed -// row). Its journal stays writable, so it is rebuilt in place on a fresh epoch, and only that chat -// is touched. Damage SQLite itself reports is not recovered here: the open fails, and the chat -// says it cannot be loaded. - -import type { AgentType } from '../../../shared/agent-status-types' -import type { - AgentJournalResetReason, - AgentSessionJournalIdentity, - AgentSessionProviderHandle -} from '../../../shared/agent-session-journal-types' -import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' -import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' - -export type AgentSessionJournalRecovery = { - trigger: 'journal_corrupt' - /** What subscribers are told; it forces a clean snapshot reload. */ - reset: AgentJournalResetReason - epoch: string - imported: number - /** Set when provider history could not be read, or held nothing to restore; the - * intact journal prefix remains live. */ - error?: string -} - -export type AgentSessionJournalOpened = { - journal: AgentSessionJournal - recovery: AgentSessionJournalRecovery | null -} - -/** The provider's own session id, which is what the transcript readers index - * by — never the Orca session id. */ -export function providerHistoryId(handle: AgentSessionProviderHandle): string { - if (handle.kind === 'codex') { - return handle.threadId - } - return handle.kind === 'claude' ? handle.sessionId : handle.value -} - -export async function openAgentSessionJournalWithRecovery(input: { - identity: AgentSessionJournalIdentity - database: JournalHostDatabase - fence: number - /** Resolve directly to a transcript instead of discovering it by session id. */ - historyFilePath?: string | null - deferPerSessionImport?: boolean -}): Promise { - const journal = await openAgentSessionJournal({ - identity: input.identity, - database: input.database, - deferPerSessionImport: input.deferPerSessionImport - }) - if (!journal.needsRebuild) { - return { journal, recovery: null } - } - // `open()` dropped the unusable suffix; a successful import rolls once more so - // the rebuilt timeline is the only content of its epoch. A throw leaves nothing to release: - // the store holds no connection. - return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) } -} - -async function rehydrate(input: { - identity: AgentSessionJournalIdentity - journal: AgentSessionJournal - fence: number - historyFilePath?: string | null - trigger: AgentSessionJournalRecovery['trigger'] -}): Promise { - const reset: AgentJournalResetReason = 'epoch_changed' - const result = await importLegacyTranscriptIntoJournal({ - journal: input.journal, - agent: input.identity.agent satisfies AgentType, - sessionId: providerHistoryId(input.identity.providerHandle), - fence: input.fence, - ...(input.historyFilePath ? { options: { filePath: input.historyFilePath } } : {}) - }) - // A transcript that held nothing is the same outcome as one that could not be - // read: nothing was restored, so the repair's marker has to stand and be - // retried on a later attach rather than being retired as a completed recovery. - if (!result.ok || !result.replaced) { - return { - trigger: input.trigger, - reset, - epoch: input.journal.epoch, - imported: 0, - error: result.ok ? 'Provider history held no messages to restore' : result.error - } - } - return { - trigger: input.trigger, - reset, - epoch: result.epoch, - imported: result.imported - } -} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts index 5ff159ce2e62..6350ba671980 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -53,7 +53,7 @@ export function deliverToSubscriber( ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) } // Caught up, so there are no rows to read: every publish behind a commit's own delivery. - if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + if (sameJournalCursor(subscriber.cursor, journal.cursor())) { emitCaughtUp(port, subscriber, emitCheckpoint, shared) return } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index c98854292d5c..d73cbb7e0bd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -473,15 +473,8 @@ describe('structured session acquisition options', () => { } return cleanup }) - const failingAdapter = { - ...base, - acquire, - readOptions, - releaseAcquisition, - ...(failurePoint === 'journal' - ? { historyFilePath: vi.fn().mockRejectedValueOnce(injected).mockResolvedValue(null) } - : {}) - } + const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition } + let journalFailure = failurePoint === 'journal' ? injected : null const perform = ( target: AgentSessionRecordStore, operationId: string, @@ -491,10 +484,14 @@ describe('structured session acquisition options', () => { logger: createStructuredAgentSessionLogger(), store: target, adapter: failingAdapter, - openConversation: openTestAttachConversation( - openTestJournalHostDatabase(root!), - failingAdapter - ), + openConversation: async (record) => { + const failure = journalFailure + journalFailure = null + if (failure) { + throw failure + } + return openTestAttachConversation(openTestJournalHostDatabase(root!))(record) + }, authority: { spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index b5d431c859a1..5edc3793d026 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -163,9 +163,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi readOptionRestoreFailures = (sessionId: string): readonly string[] => this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] - historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => - this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 318bada1fe52..9c34c1841048 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -363,9 +363,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] - /** Transcript path for journal recovery. Omit to let the existing session-file - * resolver discover it from the provider session id. */ - historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 90c638c23fb9..fe99ad90bc39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -7,7 +7,6 @@ import type { AgentSessionTurnActivity, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps, @@ -22,12 +21,6 @@ export type StructuredAgentSessionAttachContext = { runtimeState: StructuredAgentSessionHostRuntimeState sessions: Map subscribers: { - reset: ( - sessionId: string, - journal: AgentSessionJournal, - reset: AgentJournalResetReason, - fence: number - ) => void snapshot: (sessionId: string, journal: AgentSessionJournal, fence: number) => void publish: ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index 7a9f758858af..e6498597646f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -1,20 +1,18 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' /** For a test that attaches without a host: the conversation's journal, through the one open a * host would take, so the attach under test adopts it the way it adopts the host's. */ export function openTestAttachConversation( - journalDatabase: JournalHostDatabase, - adapter: Pick = {} + journalDatabase: JournalHostDatabase ): (record: AgentSessionRecord) => Promise { return async (record) => ( await openStructuredAgentSessionConversationJournal( - { journalDatabase, adapter, logger: recordingStructuredAgentSessionLogger().logger }, + { journalDatabase, logger: recordingStructuredAgentSessionLogger().logger }, record, { acquisition: true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index a001eddb3f82..a18b29b8e921 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -101,7 +101,7 @@ it('delivers catalog changes through existing frames without resending them on o }) coalescer.flush() expect(state.commands).toEqual(commands) - subscribers.reset(sessionId, journal, 'epoch_changed', 8) + subscribers.snapshot(sessionId, journal, 8) expect(state.commands).toEqual(commands) commands = undefined subscribers.open({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 73285ee1de4a..dc3d4fc42ae7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -1,17 +1,16 @@ // The one way a conversation's journal becomes open on this host: for a send, for a reader, and // for an attach that finds none open. // -// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it marks what -// an earlier host process handed over and left unanswered as in doubt, and settles what it left -// running — the crash boundary. That +// A damaged journal fails the open, which refuses it as unloadable. It marks what an earlier host +// process handed over and left unanswered as in doubt, and settles what it left running — the +// crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A // row an earlier process accepted and never handed over is the delivery loop's, which the open // wakes. Nothing here starts a provider child. -import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -20,7 +19,6 @@ import { type AgentSessionAttachParams } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { @@ -30,13 +28,10 @@ import type { export type OpenedStructuredAgentSessionConversation = { session: StructuredAgentSessionHostSession - /** Set when the journal was rebuilt on the way; readers reload from a snapshot. */ - reset: AgentJournalResetReason | null } export type StructuredAgentSessionConversationOpenDeps = { store: Pick - adapter: Pick journalDatabase: JournalHostDatabase logger: StructuredAgentSessionHostDeps['logger'] } @@ -92,17 +87,15 @@ export async function openStructuredAgentSessionConversationJournal( expectedRuntimeFence: fence }) const identity = journalIdentityFor(record, params) - const opened = await openAgentSessionJournalWithRecovery({ + const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase, - fence, - historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null, deferPerSessionImport: options.deferPerSessionImport }) try { // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over // one is only doubt, which provider history decides under a won lease. - await opened.journal.markPendingSubmissionsUnknown(fence) + await journal.markPendingSubmissionsUnknown(fence) } catch (error) { deps.logger.warn('marking pending sends unknown on open failed', { scope: 'open-pending-unknown', @@ -114,12 +107,9 @@ export async function openStructuredAgentSessionConversationJournal( // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. if (!options.acquisition) { - await settleGoneGeneration(deps, record, opened.journal) - } - return { - session: { journal: opened.journal, params, child: null }, - reset: opened.recovery?.reset ?? null + await settleGoneGeneration(deps, record, journal) } + return { session: { journal, params, child: null } } } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index c0e78b5a0c52..764d54377d02 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -191,7 +191,7 @@ function attachContext( discardEventSink: () => undefined }, sessions, - subscribers: { reset: () => undefined, snapshot: () => undefined, publish: () => undefined }, + subscribers: { snapshot: () => undefined, publish: () => undefined }, tasks: { trackAttach: (task: Promise) => task }, reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 696236805ac8..5da54bebfc56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -4,7 +4,6 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime' -import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openStructuredAgentSessionConversation, @@ -54,7 +53,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise - reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void clientDelivery: Pick }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input @@ -86,11 +84,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, opened: OpenedStructuredAgentSessionConversation ): Promise => { - const { session, reset } = opened + const { session } = opened sessions.set(sessionId, session) - if (reset) { - input.reset(sessionId, session.journal, reset) - } input.clientDelivery.publishRestored(sessionId) await settleInterruptedCommands(deps, sessionId, session) if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index f74532ac9aef..763df3080c38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -70,15 +70,29 @@ export function createStructuredAgentSessionTabSurface( deps: { store: Pick< AgentSessionRecordStore, - 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' + 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getRecord' > } }, sessions: TabSessions, forgetStatus: (sessionId: string) => void ) { + // Restored chats that could not be opened. Each keeps its tab, whose read says why. + const unopened = new Set() return { - listSessionTabs: () => listStructuredAgentSessionTabs(sessions), + listSessionTabs: (): StructuredAgentSessionTab[] => [ + ...listStructuredAgentSessionTabs(sessions), + ...[...unopened].flatMap((sessionId) => { + const record = sessions.has(sessionId) ? null : host.deps.store.getRecord(sessionId) + return record + ? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }] + : [] + }) + ], + /** A restore target with a record whose open failed. */ + markUnopened: (sessionId: string): void => { + unopened.add(sessionId) + }, getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(), getSessionTabId: (sessionId: string): string | null => host.deps.store.getSessionTabId(sessionId), @@ -89,6 +103,9 @@ export function createStructuredAgentSessionTabSurface( tabId?: string ): Promise => { await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId) + if (!visible) { + unopened.delete(sessionId) + } // The tab edge of the row's lifetime; the handle close is the other. if (!visible && !sessions.get(sessionId)?.child) { forgetStatus(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 6552f8a26016..ae75f4765916 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -2,7 +2,10 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-fail import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusSummary, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' @@ -30,6 +33,8 @@ export type StructuredAgentSessionReveal = { workspaceId: string agent: 'claude' | 'codex' readable: boolean + /** Why the journal did not open, as a read would be refused. Host-side only: never published. */ + openRefusal?: AgentSessionWireRefusal } /** Which provider child: the adapter acquisition and the lease fence it writes at. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 5b5a4d19c3c1..8256f90e53cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -126,13 +126,6 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), - reset: (sessionId, journal, reset) => - this.subscribers.reset( - sessionId, - journal, - reset, - structuredAgentSessionConversationFence(deps.store, sessionId) - ), clientDelivery: this.clientDelivery }) this.restore = createStructuredAgentSessionHostRestore(deps, { @@ -142,7 +135,8 @@ export class StructuredAgentSessionHost { hasSession: this.hasSession, // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. - onReadable: this.conversationDelivery.adoptOpened + onReadable: this.conversationDelivery.adoptOpened, + onUnopened: (sessionId) => this.tabs.markUnopened(sessionId) }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts new file mode 100644 index 000000000000..f3ce31243dad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts @@ -0,0 +1,223 @@ +// On a real host: what a newer Orca left in a chat, and a provider row the reader would reject, +// never leave the chat unable to take its next message. + +import { cp, rm } from 'node:fs/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +const relaunchedRoots: string[] = [] + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +function providerEvents() { + const events = hostTestState().acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no acquired provider') + } + return events +} + +function emit(ordinal: number, body: AgentJournalItemBody): void { + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, + body, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function send(host: StructuredAgentSessionHost, text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) +} + +async function historyBodies(host: StructuredAgentSessionHost): Promise { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return page.page.items.map((item) => item.body) +} + +/** A restarted process over the same files, holding no chat open and owning no provider. */ +async function relaunch(): Promise { + const before = hostTestState() + await before.host.flushAllStreamedEvents() + await before.store.renewLeases([]) + const relaunched = `${before.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(before.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const store = await openTestAgentSessionRecordStore(relaunched) + const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + now: () => NOW + }) + replaceHostTestState({ store, host }) + return host +} + +// A subject kind this build does not know. +const NEWER_APPROVAL: AgentJournalItemBody = JSON.parse( + JSON.stringify({ + kind: 'approval', + title: 'Review proposed change', + detail: null, + subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) +) + +it("settles a newer Orca's pending approval as it was, and the chat takes its next message", async () => { + await attach() + // Still pending when its provider went away. + emit(7, NEWER_APPROVAL) + const host = await relaunch() + + expect(await send(host, 'after the newer approval')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalled()) + + const approval = (await historyBodies(host)).find((body) => body.kind === 'approval') + expect(approval).toMatchObject({ + subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' }, + resolution: { state: 'cancelled' } + }) + await host.flushAllStreamedEvents() +}) + +// A newer host's background agent can hold such an approval while no turn runs. A queued send would +// wait behind it forever; the plain send the client makes instead starts a turn, whose card cancel +// then settles it. +it("takes a send past a newer Orca's live approval, and the next turn's card cancel settles it", async () => { + // A provider whose card Cancel goes through the Stop: a card no live turn raised is declined. + const dismissPrompt = vi.fn(async ({ commit }: { commit: () => Promise }) => commit()) + Object.assign(hostTestState().host.deps.adapter, { + routePromptCancel: () => ({ kind: 'stop' }), + dismissPrompt + }) + await attach() + emit(7, NEWER_APPROVAL) + const { host } = hostTestState() + await host.flushStreamedEvents(SESSION) + + const body = hostTestMessage('queued behind it') + const queuedFields = { body, delivery: 'queue-if-active' as const } + expect( + await host.send(CALLER, { + envelope: envelope('agentSession.send', queuedFields), + ...queuedFields, + userSend: true + }) + ).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + expect(hostTestState().dispatch).not.toHaveBeenCalled() + + const sent = await send(host, 'carry on') + expect(sent).toMatchObject({ ok: true, value: { submission: expect.any(Object) } }) + await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalledOnce()) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 8 }, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + + const card = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.body.kind === 'approval' + )! + const fields = { + turnId: 'turn-2', + prompt: { itemId: card.itemId, expectedRevision: card.revision } + } + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) + ).toMatchObject({ ok: true }) + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true })) + expect(hostTestState().cancelTurn).not.toHaveBeenCalled() + expect((await historyBodies(host)).find((entry) => entry.kind === 'approval')).toMatchObject({ + subject: { kind: 'diff' }, + resolution: { state: 'cancelled' } + }) + await host.flushAllStreamedEvents() +}) + +it('ends a turn whose provider output would not read back as failed, and the next send works', async () => { + const closeSession = vi.fn(async () => true) + Object.assign(hostTestState().host.deps.adapter, { closeSession }) + // A provider that names its generation, as the real adapters do, so its stop settles the turn. + const { acquire } = hostTestState() + const acquired = acquire.getMockImplementation()! + acquire.mockImplementation(async (input) => ({ + ...(await acquired(input)), + acquisitionGeneration: `generation-${acquire.mock.calls.length}` + })) + await attach() + emit(1, { + kind: 'status', + text: 'Turn started', + turnLifecycle: { turnId: 'turn-1', state: 'running', startedAt: NOW } + }) + emit(2, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'written first' }] }) + // TypeScript accepts it; the persisted reader rejects a call id that is only spaces. + emit(3, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] + }) + const { host, store } = hostTestState() + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + }) + + // The turn is over, with the notice any failed journal write gives. + expect(await historyBodies(host)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'turn', turnId: 'turn-1', state: 'interrupted' }), + expect.objectContaining({ kind: 'status', failure: { kind: 'hostFault' } }) + ]) + ) + expect(await send(host, 'the next message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(hostTestState().acquire).toHaveBeenCalledTimes(2)) + + const reopened = await relaunch() + expect(await historyBodies(reopened)).toContainEqual( + expect.objectContaining({ blocks: [{ type: 'text', text: 'written first' }] }) + ) + await reopened.flushAllStreamedEvents() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 3fed3c962919..f98959bc0adf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -3,7 +3,10 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalApprovalSubject, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -43,7 +46,8 @@ afterEach(async () => { async function pendingPrompt( options = [{ id: 'allow', label: 'Allow' }], /** Raise the card in a turn that is still running, rather than on the conversation. */ - inLiveTurn = false + inLiveTurn = false, + subject?: AgentJournalApprovalSubject ): Promise<{ journal: AgentSessionJournal; itemId: string }> { root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) @@ -60,6 +64,7 @@ async function pendingPrompt( kind: 'approval', title: 'Approve?', detail: null, + ...(subject ? { subject } : {}), options, resolution: { state: 'pending', @@ -282,14 +287,16 @@ describe("a card's own Cancel, as its provider answers it", () => { answer: AgentSessionPromptCancelRoute | undefined, revision = 1, endsSession = true, - inLiveTurn = true + inLiveTurn = true, + subject?: AgentJournalApprovalSubject ) { const { journal, itemId } = await pendingPrompt( [ { id: 'allow', label: 'Allow' }, { id: 'deny', label: 'Deny' } ], - inLiveTurn + inLiveTurn, + subject ) const ctx = context( journal, @@ -320,6 +327,35 @@ describe("a card's own Cancel, as its provider answers it", () => { return { result, routes, answerPrompt, dismissPrompt, card } } + describe('on an approval whose subject a newer Orca wrote, which only its Cancel answers', () => { + const NEWER_SUBJECT: AgentJournalApprovalSubject = JSON.parse('{"kind":"diff","path":"a.ts"}') + + it('has Claude decline the request, the turn going on, and keeps the subject as it was', async () => { + const { routes, dismissPrompt, card } = await cancelCard( + { kind: 'dismiss' }, + 1, + true, + true, + NEWER_SUBJECT + ) + + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true })) + expect(routes.interrupt).not.toHaveBeenCalled() + expect(routes.stop).not.toHaveBeenCalled() + expect(card).toMatchObject({ + subject: { kind: 'diff', path: 'a.ts' }, + resolution: { state: 'cancelled' } + }) + }) + + it('has Codex end the turn holding it', async () => { + const { routes, dismissPrompt } = await cancelCard(undefined, 1, true, true, NEWER_SUBJECT) + + expect(routes.interrupt).toHaveBeenCalledOnce() + expect(dismissPrompt).not.toHaveBeenCalled() + }) + }) + it('interrupts the turn holding the card for a provider that gives no answer', async () => { const { routes, answerPrompt } = await cancelCard(undefined) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 176fd8e11fb4..248534097b8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -211,9 +211,7 @@ export async function maybeQueueStructuredAgentSessionSend( if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { return null } - // A newer Orca's journal takes no new draft: the immediate path refuses the send. if ( - ctx.journal.isReadOnly || !shouldQueueStructuredAgentSessionSend({ journal: ctx.journal, record: context.deps.store.getRecord(ctx.sessionId), @@ -269,7 +267,7 @@ export class StructuredAgentSessionQueuedMessageDrain { schedule(sessionId: string): void { const journal = this.deps.sessions.get(sessionId)?.journal - if (!journal || journal.isReadOnly) { + if (!journal) { return } // Cheap pre-check so token streams do not pay a serialized step per delta. @@ -312,7 +310,7 @@ export class StructuredAgentSessionQueuedMessageDrain { private async step(sessionId: string): Promise { const session = this.deps.sessions.get(sessionId) - if (!session || session.journal.isReadOnly) { + if (!session) { return } const journal = session.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts index 2dd2f567b879..eeaafbbaff8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -1,8 +1,11 @@ -// A chat journal a newer Orca wrote opens read-only here: its cards still show, and every queue -// write (a queued send, Send-now, Delete, Resume) is refused with the words a send gets there. +// A chat journal a newer Orca wrote does not open here: its history read and every queue write (a +// queued send, Send-now, Delete, Resume) are refused as a newer Orca's chat, and nothing is written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from '../agent-session-journal/journal-host-database-test-support' import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { @@ -42,15 +45,11 @@ async function reopenOnNewerOrcaDatabase(close: () => Promise): Promise Object.defineProperty(openTestJournalHostDatabase(rig.root), 'readOnly', { value: true }) } -/** A read-only journal's history answers as a `schema_unreadable` reset carrying its page. */ -async function readOnlyQueue() { - const answer = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(answer).toMatchObject({ ok: false, reset: 'schema_unreadable' }) - const page = 'page' in answer ? answer.page : undefined - return { - cards: (page?.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })), - pause: page?.queuePause ?? null - } +/** The history read a newer Orca's chat gets: refused, as every reader's is. */ +async function expectHistoryRefused(): Promise { + await expect( + rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) } async function twoCardsBehindWork() { @@ -68,26 +67,22 @@ async function twoCardsBehindWork() { } describe("a newer Orca's journal", () => { - it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => { - const { first, second, cards } = await twoCardsBehindWork() + it('refuses the history read, a queued send, Send-now and Delete with the update words', async () => { + const { first, second } = await twoCardsBehindWork() await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION, 'evict')) - expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + await expectHistoryRefused() - // The waiting cards would queue this send behind them; the journal takes no new draft. expect(await rig.send('third', 'queue-if-active').result).toMatchObject(REFUSED_BY_NEWER_ORCA) expect(await rig.sendNow(first)).toMatchObject(REFUSED_BY_NEWER_ORCA) expect(await rig.deleteQueued(second)).toMatchObject(REFUSED_BY_NEWER_ORCA) - - expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + await expectHistoryRefused() }) it('refuses Resume of the queue a restart paused with the update words', async () => { - const { cards } = await twoCardsBehindWork() + await twoCardsBehindWork() await reopenOnNewerOrcaDatabase(() => rig.restartHostProcess()) - expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) + await expectHistoryRefused() expect(await rig.resume()).toMatchObject(REFUSED_BY_NEWER_ORCA) - - expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index a0d3dbe7b529..6fd6bb136125 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -44,7 +44,7 @@ export function wireStructuredAgentSessionQueuedMessages( onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal - if (journal && !journal.isReadOnly) { + if (journal) { void adoptEndedRestartPause(sessionId, journal, context().deps.logger) } drain.schedule(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 945f2cefbad2..6f59e5e7d5e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -56,7 +56,6 @@ let journalRoot: string const openDeps = () => ({ store, journalDatabase: openTestJournalHostDatabase(journalRoot), - adapter: {}, logger: recordingStructuredAgentSessionLogger().logger }) const opened: AgentSessionJournal[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 8f110c3e69a2..3c3cc124003a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -37,7 +37,6 @@ describe('StructuredAgentSessionReadableRestorer', () => { openDeps: { store: { getRecord: () => null, listRecords: () => records }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {}, logger: recordingStructuredAgentSessionLogger().logger }, supportsRecord: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index d5899c794e3c..fefd91a8478e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -349,9 +349,12 @@ describe('agentSessionRefusalOperationState host oracle', () => { const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') - unreadable.host.deps.adapter.historyFilePath = async () => { - throw new Error('transcript unreadable') - } + // The journal's open asks where the chat's per-chat file lives before it reads anything. + const unreadableOpen = vi + .spyOn(unreadable.host.deps.journalDatabase, 'legacyDirectoryFor') + .mockImplementation(() => { + throw new Error('journal path unreadable') + }) const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } record( await assertHostAgreement( @@ -359,7 +362,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { unreadableSend, 'agent_session_journal_unreadable', async () => { - delete unreadable.host.deps.adapter.historyFilePath + unreadableOpen.mockRestore() return { harness: unreadable, spec: unreadableSend } } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts index 74e4cfb4815d..bc40f51c4661 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -198,21 +198,24 @@ describe('a resent send id', () => { expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') }) - it('is answered from a store a newer Orca wrote, which takes no write', async () => { + it('answers unknown, never a refusal, from a store a newer Orca wrote, which takes no write', async () => { await attach() const params = sendParams('sent, then a newer Orca wrote the store') await host.send(CALLER, params) await deliveredOnce() await host.close(SESSION, 'evict') + const rowsBefore = store.listOperationRows() Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) expect(store.readOnly).toBe(true) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // This build never reads a newer Orca's chat, so the answer its journal holds stays unknown. await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true, - value: { submission: { clientMessageId: params.envelope.clientOperationId } } + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } }) expect(dispatch).toHaveBeenCalledTimes(1) + expect(store.listOperationRows()).toEqual(rowsBefore) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index b16f76c1633b..93986d5988b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -2,9 +2,10 @@ // // A different question from storage: the durable record decides which markers are still present; // this decides which of those a resume may act on. The offer, the click and the pre-send check all -// ask it, and all get the same answer. +// ask it. import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -30,6 +31,8 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { adapter: StructuredAgentSessionAdapter /** Whether the chat moved on since the offer was taken; see the offer withdrawal. */ movedOn: (marker: AgentSessionResumeMarker) => boolean + /** Whether the chat was saved by a newer Orca: its whole database, or its journal's open. */ + savedByNewerOrca: (sessionId: string) => boolean }): StructuredAgentSessionRestartCandidateReader { return (markers, leaseState) => structuredAgentSessionResumableSet({ @@ -37,6 +40,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { getRecord: deps.getRecord, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), movedOn: deps.movedOn, + savedByNewerOrca: deps.savedByNewerOrca, latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt( deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] @@ -44,3 +48,37 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { leaseState }) } + +/** The listing's reader, which skips a newer Orca's chats, and the one right before sending, which + * skips nothing for them: turning one away there would spend its offer, so its send is refused + * instead and settling keeps the offer. */ +export function createStructuredAgentSessionRestartCandidateReaders( + deps: Parameters[0] +): { + derive: StructuredAgentSessionRestartCandidateReader + deriveAtSend: StructuredAgentSessionRestartCandidateReader +} { + return { + derive: createStructuredAgentSessionRestartCandidateReader(deps), + deriveAtSend: createStructuredAgentSessionRestartCandidateReader({ + ...deps, + savedByNewerOrca: () => false + }) + } +} + +/** Chats the latest reveal found saved by a newer Orca: listing skips them and never spends their + * offers. Each reveal re-derives its chat's entry; nothing is stored. */ +export function createNewerOrcaChats(databaseIsNewer: () => boolean) { + const chats = new Set() + return { + has: (sessionId: string): boolean => databaseIsNewer() || chats.has(sessionId), + note: (sessionId: string, revealed: { openRefusal?: AgentSessionWireRefusal } | null): void => { + if (revealed?.openRefusal?.details?.reason === 'journalWrittenByNewerOrca') { + chats.add(sessionId) + } else { + chats.delete(sessionId) + } + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 973d7758358d..8f846bb6f1a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -105,12 +105,32 @@ it.each([ ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', - reason: code + reason: code, + refusal: { code } }) expect(deps.awaitSettlement).not.toHaveBeenCalled() expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note) }) +// A newer Orca's refusal keeps its reason, as a reference without the wire prose. +it('reports a newer-Orca send refusal with its reason', async () => { + const deps = dependencies('accepted') + const refusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } as const + deps.send.mockResolvedValue({ ok: false, refusal: { ...refusal, message: 'Update Orca.' } }) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') + ).resolves.toEqual({ + sessionId: SESSION, + outcome: 'refused', + reason: 'agent_session_journal_unreadable', + refusal + }) +}) + it('reports an unattached chat without sending', async () => { const deps = dependencies('accepted') deps.currentFence = () => null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index c748748ccd41..1a66b5561031 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -14,12 +14,16 @@ import { readAgentSessionFailureFact, type UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentSessionRefusalReference, + type AgentSessionRefusalReference +} from '../../../shared/agent-session-wire-refusals' import { agentSessionSendSubmission, type AgentSessionMutationEnvelope, type AgentSessionMutationResult, - type AgentSessionSendResult + type AgentSessionSendResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' @@ -164,7 +168,7 @@ export type StructuredAgentSessionContinuationDeps = { body: AgentJournalMessageItem }) => Promise<{ ok: boolean - refusal?: { code: string } + refusal?: AgentSessionWireRefusal /** The submission is where the provider's answer lives; the envelope only says Orca took it. * A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so * the host's union return stays assignable. */ @@ -314,7 +318,9 @@ async function sendContinuation( done: { sessionId, outcome: 'refused', - reason: sent.refusal?.code ?? 'agent_session_send_failed' + reason: sent.refusal?.code ?? 'agent_session_send_failed', + // Its details too, so a newer Orca's refusal is filed as one, not as a bare code. + ...(sent.refusal ? { refusal: agentSessionRefusalReference(sent.refusal) } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 63722d1737cc..f69e9ced6dc4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -58,10 +58,11 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every record this host + * lists goes (a newer Orca's stay). */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void + beforeClearAll: () => void | Promise ) => Promise } @@ -78,6 +79,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ retryable: (marker: AgentSessionResumeMarker) => boolean + /** Whether a newer Orca saved the chat: its failure is kept for that Orca but not shown here, + * where no retry can land. */ + savedByNewerOrca: (sessionId: string) => boolean /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise logger: StructuredAgentSessionLogger @@ -101,7 +105,11 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failure: AgentSessionResumeFailureRecord ): StructuredAgentSessionResumeFailure[] => { const record = deps.getRecord(failure.marker.sessionId) - if (!record || !adapterSupportsRecord(deps.adapter, record)) { + if ( + !record || + !adapterSupportsRecord(deps.adapter, record) || + deps.savedByNewerOrca(failure.marker.sessionId) + ) { return [] } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) @@ -153,6 +161,15 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { ]) for (const outcome of outcomes) { const resumed = outcome.outcome === 'resumed' + // A newer Orca's refusal spends nothing: the rollback below reopens the offer for the Orca + // that can continue it, and nothing is filed for a chat no retry here could continue. + if ( + !resumed && + outcome.reason === 'agent_session_journal_unreadable' && + outcome.details?.reason === 'journalWrittenByNewerOrca' + ) { + continue + } // Ineligible means the offer no longer applies (record gone, conversation forked), and // superseded means the user's own message came first: nothing to retry, and the offer is spent. const failure = resumed @@ -216,8 +233,10 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { if (sessionIds !== undefined) { return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 } - beforeClearAll() - return (await deps.capsule?.clearAll(deps.now())) ?? 0 + await beforeClearAll() + // A newer Orca's offers and failures were never shown here, so "dismiss all" keeps them. + const keep = (marker: AgentSessionResumeMarker) => deps.savedByNewerOrca(marker.sessionId) + return (await deps.capsule?.clearAll(deps.now(), keep)) ?? 0 }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts new file mode 100644 index 000000000000..e2f2087d4429 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -0,0 +1,198 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RECOVERY_CAPSULE_FILE, + AgentSessionRecoveryCapsule +} from '../../runtime/agent-session-recovery-capsule' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AgentSessionJournalError } from '../agent-session-journal/journal-write-guards' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { attachParams, CALLER, hostTestState } from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' + +// Nothing here can continue a chat a newer Orca saved, so it is not offered after a restart and +// nothing is counted. Its offer is kept: the offers file is shared by every Orca on the host, and +// the newer one can still act on it. Nothing here writes to that file for such a chat. + +afterEach(() => vi.restoreAllMocks()) + +/** Writes a row of a kind this build does not know into the chat's journal, as a newer Orca would. */ +function newerOrcaRow(root: string, sessionId: string = SESSION): void { + const { db } = openTestJournalHostDatabase(root) + const last = liveTestJournalRows(db, sessionId).at(-1) + const parsed: unknown = last ? JSON.parse(last.rowJson) : null + if (!last || typeof parsed !== 'object' || parsed === null || !('epoch' in parsed)) { + throw new Error('the chat has no journal row to follow') + } + const { epoch } = parsed + const seq = last.seq + 1 + insertTestJournalRowJson( + db, + sessionId, + seq, + JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch, + seq, + fence: 1, + ts: NOW + }), + NOW + ) +} + +function offersFile(root: string): Promise { + return readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE)) +} + +/** Lists and acts with nothing offered, run or counted, and the offers file left byte-identical. */ +async function keptWithoutAWord( + host: Awaited>['host'], + root: string, + acquire: ReturnType +) { + const before = await offersFile(root) + expect(await host.restartResume.list()).toEqual([]) + expect(await offersFile(root)).toEqual(before) + expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([]) + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.listFailures()).toEqual([]) + expect(await offersFile(root)).toEqual(before) + const capsule = new AgentSessionRecoveryCapsule(root) + expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION]) + expect(await capsule.listFailed(NOW)).toEqual([]) +} + +it('keeps the offer of a chat whose journal a newer Orca wrote, and offers, runs and counts nothing', async () => { + const { host, root, acquire } = await interruptedRestart() + newerOrcaRow(root) + await keptWithoutAWord(host, root, acquire) +}) + +// A newer Orca's whole database: every chat in it is a newer Orca's, even one whose tables changed. +it('keeps the offer of every chat in a newer Orca database, and offers, runs and counts nothing', async () => { + const { host, root, acquire } = await interruptedRestart() + const database = openTestJournalHostDatabase(root) + Object.defineProperty(database, 'readOnly', { value: true }) + database.db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_newer') + await keptWithoutAWord(host, root, acquire) +}) + +// However a newer Orca's refusal reaches a send (a journal first opened when sending), it files +// nothing and the offer is reopened. +it('keeps the offer and files nothing when the send is refused as a newer Orca chat', async () => { + const { host, root } = await interruptedRestart() + await host.restartResume.list() + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockRejectedValue( + new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal') + ) + + const action = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(action.continued).toMatchObject([ + { + outcome: 'refused', + reason: 'agent_session_journal_unreadable', + refusal: { details: { reason: 'journalWrittenByNewerOrca' } } + } + ]) + const capsule = new AgentSessionRecoveryCapsule(root) + expect(await capsule.listFailed(NOW)).toEqual([]) + expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION]) + expect(await host.restartResume.listFailures()).toEqual([]) +}) + +// A failure filed before the chat became a newer Orca's stays for that Orca, and is not shown here. +it('keeps a failure already filed for a chat a newer Orca saved since, but does not show it', async () => { + const { host, root, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(await host.restartResume.listFailures()).toMatchObject([{ sessionId: SESSION }]) + await host.close(SESSION, 'evict') + newerOrcaRow(root) + + expect(await host.restartResume.listFailures()).toEqual([]) + expect( + (await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).map((f) => f.marker.sessionId) + ).toEqual([SESSION]) +}) + +// What listing does with a journal that throws for another reason: a damaged chat stays offered, +// and acting on it files the failure, which names the damage. +it('still offers a damaged chat, and files its failure when acting on it', async () => { + const { host, root, acquire } = await interruptedRestart() + updateTestJournalRowJson(openTestJournalHostDatabase(root).db, SESSION, 1, '}{') + + expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([ + SESSION + ]) + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.listFailures()).toMatchObject([ + { + sessionId: SESSION, + reason: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + ]) +}) + +// "Dismiss all" ends what the person was shown. A newer Orca's offer was never shown here, so it +// stays, byte for byte, for the Orca that can act on it. +it.each([ + ['after the dialog listed the offers', true], + ['on a host that has not listed them yet', false] +])( + "dismisses every listed offer and leaves a newer Orca's hidden one as it was, %s", + async (_when, listFirst) => { + const NEWER = 'session-newer-orca' + // A second chat, made before the restart; a newer Orca then saves it with a row this build + // can't place. + const created = await hostTestState().host.attach( + CALLER, + attachParams({ envelope: { ...attachParams().envelope, sessionId: NEWER } }) + ) + expect(created.ok).toBe(true) + hostTestState().acquire.mockClear() + const { host, root, marker } = await interruptedRestart() + newerOrcaRow(root, NEWER) + const path = join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) + const capsule = JSON.parse(await readFile(path, 'utf8')) + capsule.entries.push({ state: 'pending', marker: { ...marker, sessionId: NEWER } }) + await writeFile(path, JSON.stringify(capsule)) + const newerEntry = () => + readFile(path, 'utf8').then((text) => + JSON.stringify( + JSON.parse(text).entries.find( + (entry: { marker: { sessionId: string } }) => entry.marker.sessionId === NEWER + ) + ) + ) + const before = await newerEntry() + + if (listFirst) { + expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([ + SESSION + ]) + } + await host.restartResume.dismiss() + + const left = await new AgentSessionRecoveryCapsule(root).list(NOW) + expect(left.map((offer) => offer.sessionId)).toEqual([NEWER]) + expect(await newerEntry()).toBe(before) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts index 95c97be63a92..4b458002ab90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts @@ -14,6 +14,8 @@ export type StructuredAgentSessionRestartOfferRecords = { sessionIds: readonly string[] | undefined ) => Promise revealMarkers: (markers: readonly AgentSessionResumeMarker[]) => Promise + /** Reveals the chat of every offer and every recorded failure. */ + revealEvery: () => Promise /** A user's newer message ended these offers; delete them rather than re-filter forever. * Advisory: a failed prune must never fail the read that noticed it. */ retireSuperseded: (superseded: readonly AgentSessionResumeMarker[]) => void @@ -41,6 +43,13 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { return [] } } + const revealMarkers = async (markers: readonly AgentSessionResumeMarker[]): Promise => { + for (const marker of markers) { + if (!deps.hasSession(marker.sessionId)) { + await deps.reveal(marker.sessionId) + } + } + } return { readMarkers, readActionMarkers: async (sessionIds) => { @@ -54,13 +63,9 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { ) return [...pending, ...retried] }, - revealMarkers: async (markers) => { - for (const marker of markers) { - if (!deps.hasSession(marker.sessionId)) { - await deps.reveal(marker.sessionId) - } - } - }, + revealMarkers, + revealEvery: async () => + revealMarkers([...(await readMarkers()), ...(await deps.readFailedMarkers())]), retireSuperseded: (superseded) => { const capsule = deps.capsule if (!capsule || superseded.length === 0) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 441d4e80626d..7df838d04e82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -30,7 +30,6 @@ afterAll(() => { const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {}, logger: recordingStructuredAgentSessionLogger().logger } @@ -52,8 +51,7 @@ describe('restart journal restoration', () => { params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, child: null, sessionId - }, - reset: null + } } }) const records = Array.from( @@ -139,8 +137,7 @@ describe('restart journal restoration', () => { runtimeKind: 'native' } const restored = { - session: { journal: {}, params, child: null }, - reset: null + session: { journal: {}, params, child: null } } // The open is what settles: it runs after recovery resolution and before the publish. restoreRead.mockImplementation(async () => { @@ -219,10 +216,46 @@ describe('restart journal restoration', () => { }) }) + it('opens every other chat when one chat cannot be opened, and the pass still settles', async () => { + restoreRead.mockImplementation(async (_deps, sessionId: string) => { + if (sessionId === 'session-0') { + throw new Error('this chat cannot be opened') + } + return { session: { journal: {}, params: {}, child: null, sessionId } } + }) + const readable: string[] = [] + const log = recordingStructuredAgentSessionLogger() + const records = Array.from( + { length: 12 }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + + await expect( + restoreStructuredAgentSessionsOnRestart({ + openDeps: { ...NO_OPEN_DEPS, logger: log.logger }, + records, + reconcile: async () => true, + resolveRecovery: async () => true, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: (sessionId) => { + readable.push(sessionId) + } + }) + ).resolves.toBeUndefined() + + expect(readable).toEqual(records.slice(1).map((record) => record.sessionId)) + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: expect.objectContaining({ scope: 'restart-restore', sessionId: 'session-0' }) + }) + ]) + }) + it('does not settle again when a second restore finds the session already open', async () => { restoreRead.mockResolvedValue({ - session: { journal: {}, params: {}, child: null }, - reset: null + session: { journal: {}, params: {}, child: null } }) await restoreStructuredAgentSessionsOnRestart({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 29ea17845d8a..6c6efc49d728 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -34,6 +34,8 @@ export type StructuredAgentSessionReadRestoreDeps = { resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean + /** A chat whose open failed: it still has its record, so it keeps its tab. */ + onUnopened?: (sessionId: string) => void onReadable: ( sessionId: string, opened: OpenedStructuredAgentSessionConversation @@ -92,6 +94,16 @@ export async function restoreStructuredAgentSessionsOnRestart( await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. await yieldToEventLoop() - await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases) + // One chat that cannot open must not keep the rest from restoring; a read of it still refuses. + await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases).catch( + (error: unknown) => { + input.openDeps.logger.warn('restoring a chat at startup failed', { + scope: 'restart-restore', + sessionId, + error + }) + input.onUnopened?.(sessionId) + } + ) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index ef8481e2b1f5..83dedb1709d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -14,7 +14,10 @@ import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' +import { + createNewerOrcaChats, + createStructuredAgentSessionRestartCandidateReaders +} from './structured-agent-session-restart-candidates' import { continuationFailureOutcome, createStructuredAgentSessionRestartFailureLedger @@ -44,6 +47,7 @@ import { import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type LiveSession = StructuredAgentSessionRestartOfferSession @@ -67,7 +71,8 @@ export type StructuredAgentSessionRestartResume = { sessions?: StructuredAgentSessionResumeCandidate[] failed?: StructuredAgentSessionResumeFailure[] }> - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every record this host + * lists goes (a newer Orca's stay). */ dismiss: (sessionIds?: readonly string[]) => Promise /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ onAgentStarted: (sessionId: string) => void @@ -79,6 +84,7 @@ export function createStructuredAgentSessionRestartResume( adapter: StructuredAgentSessionAdapter recoveryCapsule?: AgentSessionRecoveryCapsule logger: StructuredAgentSessionLogger + journalDatabase: Pick }, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces @@ -102,30 +108,33 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) - const derive = createStructuredAgentSessionRestartCandidateReader({ + const newerOrca = createNewerOrcaChats(() => deps.journalDatabase.readOnly) + const { derive, deriveAtSend } = createStructuredAgentSessionRestartCandidateReaders({ sessions, getRecord: deps.store.getRecord, adapter: deps.adapter, - movedOn: withdrawal.movedOn + movedOn: withdrawal.movedOn, + savedByNewerOrca: newerOrca.has }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, + savedByNewerOrca: newerOrca.has, reveal: (markers) => revealMarkers(markers), logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) - const { readMarkers, readActionMarkers, revealMarkers, retireSuperseded } = + const { readMarkers, readActionMarkers, revealMarkers, revealEvery, retireSuperseded } = createStructuredAgentSessionRestartOfferRecords({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), readFailedMarkers: async () => (await failures.read()).map((failure) => failure.marker), hasSession: (sessionId) => sessions.has(sessionId), reveal: async (sessionId) => { - await surfaces.revealSession(sessionId).catch(() => null) + newerOrca.note(sessionId, await surfaces.revealSession(sessionId).catch(() => null)) }, logger: deps.logger, now: surfaces.now, @@ -150,7 +159,7 @@ export function createStructuredAgentSessionRestartResume( deps.store.getRecord(sessionId) ? structuredAgentSessionConversationFence(deps.store, sessionId) : null, - stillResumable: (marker) => derive([marker], 'may-be-held').candidates.length === 1 + stillResumable: (marker) => deriveAtSend([marker], 'may-be-held').candidates.length === 1 } /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at @@ -194,7 +203,9 @@ export function createStructuredAgentSessionRestartResume( admission, consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) - return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 + return ( + marker !== undefined && deriveAtSend([marker], 'may-be-held').candidates.length === 1 + ) }, resume: async (sessionId) => { const marker = markersBySession.get(sessionId) @@ -310,7 +321,12 @@ export function createStructuredAgentSessionRestartResume( listFailures: failures.list, // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. - dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), + // "Dismiss all" keeps what this host does not list, so it reveals every record's chat first. + dismiss: (sessionIds) => + failures.dismiss(sessionIds, async () => { + witnesses.clear() + await revealEvery() + }), continueAfterRestart, onAgentStarted: withdrawal.onAgentStarted } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 3c91a9732b9b..9df4e392bc3c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -79,6 +79,8 @@ export type StructuredAgentSessionResumeSetInput = { latestPrompt: (sessionId: string) => string /** Whether the chat moved on since the offer was taken; false when its journal is not open here. */ movedOn: (marker: AgentSessionResumeMarker) => boolean + /** Whether the chat was saved by a newer Orca: its whole database, or its journal's open. */ + savedByNewerOrca: (sessionId: string) => boolean /** * Whether the lease must be free. * @@ -119,6 +121,11 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } + // Nothing here can continue a chat a newer Orca saved, so it is not offered. Its offer is kept, + // not spent: the offers file is shared by every Orca on the host, and the newer one can act on it. + if (input.savedByNewerOrca(marker.sessionId)) { + continue + } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) candidates.push({ sessionId: marker.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 93c33146743b..9f00c69aca49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -136,7 +136,6 @@ export function claudeRecord( /** The fake journal's own shape, so callers can wire `appendItem` without reaching into `unknown`. * The code under test sees the real `AgentSessionJournal` type; tests see this. */ export type HarnessJournal = { - isReadOnly: boolean snapshot: () => { items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] @@ -150,12 +149,10 @@ export type HarnessSession = { journal: HarnessJournal; child: { fence: number } export function journal( items: AgentJournalRenderItem[], - isReadOnly = false, submissions: AgentJournalSubmission[] = [] ) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only isReadOnly, snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store. return { - isReadOnly, snapshot: () => ({ items, submissions, cursor: { epoch: EPOCH, sequence: items.length } }), submissions: () => submissions, appendItem: async () => undefined @@ -219,12 +216,15 @@ export function resumableSet(input: { markers: AgentSessionResumeMarker[] items?: AgentJournalRenderItem[] chain?: AgentSessionRecord['providerHandleChain'] + /** Sessions a newer Orca saved. */ + savedByNewerOrca?: string[] }) { return structuredAgentSessionResumableSet({ markers: input.markers, getRecord: () => record(input.chain === undefined ? {} : { chain: input.chain }), supportsRecord: () => true, latestPrompt: () => 'fix the auth bug', - movedOn: () => false + movedOn: () => false, + savedByNewerOrca: (sessionId) => input.savedByNewerOrca?.includes(sessionId) === true }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 2284367c14b8..1c17dc11e301 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -8,14 +8,17 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-jour import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, - AgentSessionSendResult + AgentSessionSendResult, + AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' export type StructuredAgentSessionRestartResumeSurfaces = { - revealSession: (sessionId: string) => Promise<{ readable: boolean }> + revealSession: ( + sessionId: string + ) => Promise<{ readable: boolean; openRefusal?: AgentSessionWireRefusal }> send: (input: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem @@ -41,7 +44,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = /** Exactly the host members this binds. Structural, so the host satisfies it without declaring a * dependency, and nothing outside this list is reachable from here. */ type RestartResumeHostBindings = { - revealSession: (sessionId: string) => Promise<{ readable: boolean }> + revealSession: StructuredAgentSessionRestartResumeSurfaces['revealSession'] send: ( caller: { callerKey: string }, params: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index a2ed07db2916..b16c7277fc10 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -288,7 +288,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([], false, [submission('msg-1', 'pending')]), + journal: journal([], [submission('msg-1', 'pending')]), child: { fence: 1 } } ] @@ -311,9 +311,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-0', 'completed')], false, [ - submission('msg-1', 'pending') - ]), + journal: journal([turnItem('turn-0', 'completed')], [submission('msg-1', 'pending')]), child: { fence: 1 } } ] @@ -338,7 +336,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-0', 'completed')], false, [queued]), + journal: journal([turnItem('turn-0', 'completed')], [queued]), child: { fence: 1 } } ] @@ -364,14 +362,14 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-0', 'completed')], false, [handedOver, queued]), + journal: journal([turnItem('turn-0', 'completed')], [handedOver, queued]), child: { fence: 1 } } ], [ 'session-running', { - journal: journal([turnItem('turn-1', 'running')], false, [queued]), + journal: journal([turnItem('turn-1', 'running')], [queued]), child: { fence: 1 } } ] @@ -397,9 +395,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-1', 'running')], false, [ - submission('msg-1', 'accepted') - ]), + journal: journal([turnItem('turn-1', 'running')], [submission('msg-1', 'accepted')]), child: { fence: 1 } } ] @@ -455,7 +451,8 @@ describe('the resumable set', () => { getRecord: () => claudeRecord('5aed93d6-advanced-leaf'), supportsRecord: () => true, latestPrompt: () => '', - movedOn: () => false + movedOn: () => false, + savedByNewerOrca: () => false }) expect(candidates).toHaveLength(1) @@ -470,13 +467,33 @@ describe('the resumable set', () => { getRecord: () => claudeRecord(null, 'prov-session-2'), supportsRecord: () => true, latestPrompt: () => '', - movedOn: () => false + movedOn: () => false, + savedByNewerOrca: () => false }) expect(set.candidates).toEqual([]) expect(set.superseded).toEqual([forked]) }) + // Nothing here can continue a chat a newer Orca saved: it is not offered, and its offer is kept + // for the Orca that can act on it. + it("neither offers nor spends a newer Orca's chat, and offers it once this build can open it", () => { + const offer = marker() + expect(resumableSet({ markers: [offer], savedByNewerOrca: [SESSION] })).toEqual({ + candidates: [], + superseded: [] + }) + expect(resumableSet({ markers: [offer] }).candidates).toHaveLength(1) + }) + + // Being a newer Orca's chat does not keep a forked chat's offer alive: the fork still ends it. + it('still withdraws a forked chat a newer Orca saved', () => { + const forked = marker({ providerHandleRoot: 'codex:"other-thread"' }) + expect(resumableSet({ markers: [forked], savedByNewerOrca: [SESSION] }).superseded).toEqual([ + forked + ]) + }) + // An offer has no expiry, however old it is. it('still offers a months-old marker', () => { const monthsAgo = NOW - 90 * 24 * 60 * 60 * 1000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 3046aa971f67..c95d8f6d7bae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -12,6 +12,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' @@ -42,9 +43,9 @@ export async function revealStructuredAgentSession( // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a // contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing // here would hide the one view of a session a user needs when its ownership is in doubt. - const readable = await openConversation(sessionId).then( - () => true, - () => false + const openRefusal = await openConversation(sessionId).then( + () => null, + (error: unknown) => journalOpenRefusal(error) ) return { sessionId, @@ -52,7 +53,8 @@ export async function revealStructuredAgentSession( // to aim the tab publication at another workspace. workspaceId: record.location.workspaceId, agent: record.provider, - readable + readable: openRefusal === null, + ...(openRefusal ? { openRefusal } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index dc8eb1117005..5a359deebad3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,4 +1,7 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -478,6 +481,48 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) + it("keeps a newer Orca's item of an unknown kind, in its place, through a Codex provider hydration", async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal: 0 + }) + const newerRow = { provider: 'orca' as const, clientMessageId: 'newer-card' } + // A kind this build does not know; no provider's history holds it. + const newerBody: AgentJournalItemBody = JSON.parse( + JSON.stringify({ kind: 'plan-card', steps: [{ text: 'by a newer build' }] }) + ) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(newerRow, newerBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await host.flushStreamedEvents(HOST_TEST_SESSION) + const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }] + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + return { ok: true, items } + }) + + expect( + await host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).toMatchObject({ ok: true }) + + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) + ).toEqual([ + { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') }, + { itemId: agentJournalItemKey(newerRow), body: newerBody } + ]) + }) + it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string, ordinal = 0) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index f95579d7750d..ee9c69bf38cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -77,9 +77,6 @@ export async function rewindStructuredAgentSession( if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) { return rewindRefusal('busy') } - if (ctx.journal.isReadOnly) { - return rewindRefusal('unsupported') - } const snapshot = ctx.journal.snapshot() const providerKeys = new Map( snapshot.submissions.flatMap((submission) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index f14393df8740..429cb803794c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -131,15 +131,16 @@ function startAgent(): Promise { describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { - const historyFilePath = vi - .fn>() - .mockRejectedValueOnce(new Error('journal path unavailable')) - .mockResolvedValue(null) + const journalDatabase = openTestJournalHostDatabase(root) + // The journal's open asks where the chat's per-chat file lives before it reads anything. + vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { + throw new Error('journal path unavailable') + }) host = new StructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), store, - adapter: { ...adapter(), historyFilePath }, - journalDatabase: openTestJournalHostDatabase(root), + adapter: adapter(), + journalDatabase, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index b71022b92c70..caabf348133c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -189,14 +189,14 @@ it('refuses a send over records a newer Orca wrote with the update words', async }) }) -it('restores a chat for reading from records a newer Orca wrote', async () => { +it('opens no chat from records a newer Orca wrote, and writes nothing trying', async () => { const { host, stateDirectory, leaseReconcileLogged } = await relaunch(true) const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(true) + expect(host.hasSession(SESSION)).toBe(false) expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(await readFile(path)).toEqual(bytes) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 743a8e2287c4..6409c5441296 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -666,7 +666,7 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) }) - it('invalidates cached status on unreadability and keeps record metadata live', async () => { + it('keeps record metadata live', async () => { const journal = await openJournal() await journal.appendItem( USER_IDENTITY, @@ -681,12 +681,6 @@ describe('StructuredAgentSessionStatusFeed', () => { type: 'status', session: { status: 'idle', model: 'second-model' } }) - const readOnly = vi.spyOn(journal, 'isReadOnly', 'get').mockReturnValue(true) - feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: null } }) - readOnly.mockRestore() - feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 04be126d418d..24581c8fca28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -241,12 +241,8 @@ export class StructuredAgentSessionStatusFeed { private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, - acceptedSendKey: string | null + acceptedSendKey: string ): void { - // An unreadable journal says nothing about the user's turns: the last send read stands. - if (acceptedSendKey === null) { - return - } const seen = this.acceptedSends.has(sessionId) const previous = this.acceptedSends.get(sessionId) this.acceptedSends.set(sessionId, acceptedSendKey) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index 04ce86d36bca..87b440ef37a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -13,11 +13,9 @@ export type StructuredAgentSessionStatusState = ReturnType< export type StructuredAgentSessionJournalProjection = { epoch: string sequence: number - readOnly: boolean fence: number | undefined state: StructuredAgentSessionStatusState - /** Null for an unreadable journal, which says nothing about the user's turns. */ - acceptedSendKey: string | null + acceptedSendKey: string } export class StructuredAgentSessionJournalProjections { @@ -31,9 +29,7 @@ export class StructuredAgentSessionJournalProjections { journal: AgentSessionJournal, record: AgentSessionRecord | null ): StructuredAgentSessionJournalProjection { - // An unreadable journal projects as "no turn": the chat itself shows the reset. const cursor = journal.cursor() - const readOnly = journal.isReadOnly // The conversation's fence, which a child's end moves: its unanswered sends stop counting. const fence = record?.lease.runtimeFence let projection = this.byJournal.get(journal) @@ -41,24 +37,20 @@ export class StructuredAgentSessionJournalProjections { !projection || projection.epoch !== cursor.epoch || projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || projection.fence !== fence ) { // A journalled submission bumps `lastSequence`, so the send-time working // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() + const snapshot = journal.snapshot() projection = { ...cursor, - readOnly, fence, state: projectStructuredAgentSessionStatusState( - snapshot?.items ?? [], - snapshot?.submissions ?? [], + snapshot.items, + snapshot.submissions, fence ), - acceptedSendKey: snapshot - ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? []) - : null + acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions) } this.byJournal.set(journal, projection) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 3881e8c429f2..27ae0cb0608a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -125,7 +125,7 @@ describe('AgentSessionSubscribers', () => { ) subscribers.publish(SESSION, journal) subscribers.backgroundTasks(SESSION, null, 1) - subscribers.reset(SESSION, journal, 'epoch_changed', 1) + subscribers.snapshot(SESSION, journal, 1) expect(events.map((event) => ('hostNow' in event ? event.hostNow : null))).toEqual([ 1_001, 1_002, @@ -139,8 +139,8 @@ describe('AgentSessionSubscribers', () => { 'batch', 'batch', 'batch', - 'reset', - 'reset' + 'snapshot', + 'snapshot' ]) }) @@ -207,10 +207,9 @@ describe('AgentSessionSubscribers', () => { }) subscribers.publish(SESSION, journal) - subscribers.reset(SESSION, journal, 'epoch_changed', 1) subscribers.snapshot(SESSION, journal, 1) - expect(published).toEqual([SESSION, SESSION, SESSION]) + expect(published).toEqual([SESSION, SESSION]) }) it('settles a session nobody is reading, from running to idle', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index 1b7bd87a9f27..a61d5a572406 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -4,10 +4,7 @@ // ago gets a reset while a caught-up one gets a batch from the same publish. // Nothing raw reaches a subscriber — every event carries reducer output. -import type { - AgentJournalCursor, - AgentJournalResetReason -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState, AgentSessionSlashCommand, @@ -142,39 +139,18 @@ export class AgentSessionSubscribers { } } - /** Force every subscriber back to a bounded tail page — recovery, epoch - * rollover, an unreadable schema. */ - reset( - sessionId: string, - journal: AgentSessionJournal, - reason: AgentJournalResetReason, - fence: number, - backgroundTasks?: AgentSessionBackgroundTaskState | null - ): void { - this.replay(sessionId, journal, fence, backgroundTasks, { type: 'reset', reset: reason }) - } - + /** Every subscriber back to a bounded tail page. */ snapshot( sessionId: string, journal: AgentSessionJournal, fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null - ): void { - this.replay(sessionId, journal, fence, backgroundTasks, { type: 'snapshot' }) - } - - private replay( - sessionId: string, - journal: AgentSessionJournal, - fence: number, - backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, - frame: { type: 'snapshot' } | { type: 'reset'; reset: AgentJournalResetReason } ): void { const page = readAgentSessionHydrationPage(journal, fence) const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { this.emit(subscriber, { - ...frame, + type: 'snapshot', sessionId, page, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 1f8fc80c863d..5a1ad69f6130 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -544,6 +544,36 @@ describe('an unexpected provider exit', () => { replaceFailedSink() }) + // TypeScript accepts the body; the persisted reader rejects a call id that is only spaces. + it('stops a provider whose output would not read back, and the next send starts a child', async () => { + await attach() + + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + }) + expect(hostErrors).toContainEqual(expect.objectContaining({ code: 'journal_row_rejected' })) + replaceFailedSink() + + // Nothing restarts it on its own; the next send does, and the chat still loads. + expect(acquire).toHaveBeenCalledOnce() + await startAgent() + expect(acquire).toHaveBeenCalledTimes(2) + emitTurnLifecycle('running', 2) + await host.flushAllStreamedEvents() + await reboot() + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) + }) + it('settles a journal sink failure whose stop saw the provider root exit', async () => { await attach() // The lease follows the root, so its seen exit settles like a proven one. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index a90a65c1c831..84339a7587d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -106,7 +106,7 @@ export class StructuredAgentSessionTurnCompletionFeed { return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { - // Epoch replacement (rewind, repair, or legacy import) republishes history with a new + // Epoch replacement (rewind or legacy import) republishes history with a new // identity. It is not a provider edge, so re-baseline silently instead of announcing the // newest settled row as a fresh completion. baseline.epoch = cursor.epoch diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts new file mode 100644 index 000000000000..170f58eba2fe --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts @@ -0,0 +1,115 @@ +// A restored chat whose open fails keeps its tab, so a phone on a headless host still lists it and +// its read says why, instead of the chat disappearing: damaged, or saved by a newer Orca. + +import { cp, rm } from 'node:fs/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import { + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +const relaunchedRoots: string[] = [] + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A restarted process over the chat's files, its epoch row rewritten by `edit`. */ +async function relaunchWith( + edit: (db: Database.Database) => void +): Promise { + const before = hostTestState() + await attach() + await before.host.flushStreamedEvents(SESSION) + await before.store.renewLeases([]) + const relaunched = `${before.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(before.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + edit(openTestJournalHostDatabase(relaunched).db) + const store = await openTestAgentSessionRecordStore(relaunched) + const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + now: () => NOW + }) + replaceHostTestState({ store, host }) + return host +} + +const damaged = (db: Database.Database) => updateTestJournalRowJson(db, SESSION, 1, '}{') +const savedByNewerOrca = (db: Database.Database) => { + const epochRow = liveTestJournalRows(db, SESSION).find((row) => row.seq === 1)! + const newer = { ...JSON.parse(epochRow.rowJson), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 } + updateTestJournalRowJson(db, SESSION, 1, JSON.stringify(newer)) +} + +it.each([ + ['damaged', damaged, 'journalCorrupt'], + ['saved by a newer Orca', savedByNewerOrca, 'journalWrittenByNewerOrca'] +] as const)( + 'lists a restored chat that could not be opened (%s), and its read and send are refused with why', + async (_why, edit, reason) => { + const host = await relaunchWith(edit) + const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId + const refusal = { code: 'agent_session_journal_unreadable', details: { reason } } + + await host.restoreReadableSessions([SESSION]) + + expect(host.hasSession(SESSION)).toBe(false) + expect(host.listSessionTabs()).toEqual([ + { sessionId: SESSION, workspaceId, agent: expect.any(String) } + ]) + // Every read is refused with why, and the host logs it once for the chat. + for (let read = 0; read < 3; read += 1) { + await expect(host.history({ sessionId: SESSION, direction: 'tail' })).rejects.toMatchObject({ + refusal + }) + } + const readFailureLogs = vi + .mocked(console.warn) + .mock.calls.filter(([line]) => String(line).includes('open-for-read')) + expect(readFailureLogs).toHaveLength(1) + const body = hostTestMessage('sent to a chat that cannot load') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: false, refusal }) + + await host.setSessionTabVisibility(SESSION, false) + expect(host.listSessionTabs()).toEqual([]) + await host.flushAllStreamedEvents() + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 11647f2eefde..217360673fce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -178,8 +178,7 @@ export function structuredAgentSessionWorkingAtStop(input: { now: number }): AgentSessionResumeMarker | null { const { sessionId, session } = input - // A journal this host cannot read tells us nothing about what the turn was doing. - if (!session?.child || session.journal.isReadOnly) { + if (!session?.child) { return null } const snapshot = session.journal.snapshot() diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 9de1925791fa..c110a935aea8 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -24,6 +24,22 @@ describe('rewind recovery of newer durable records', () => { }) }) + // Retained by its place, never by its kind, so an updated Orca still has it after the rewind. + it('carries an item of a kind this build does not know as it was, and records it', () => { + const body = { kind: 'plan-card', title: 'by a newer build' } + expect(restoreRewindJournalBody(body)).toBe(body) + expect( + AgentSessionRewindRecordSchema.safeParse({ + operationId: 'op-1', + callerKey: 'caller-1', + itemId: 'codex:thread-1:turn-1:3', + expectedEpoch: 'epoch-1', + phase: 'prepared', + retained: [{ itemId: 'codex:thread-1:turn-1:1', body, observedAt: 1 }] + }).success + ).toBe(true) + }) + it('preserves background-task blocks across rewind recovery', () => { const body = { kind: 'message' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index 99489b18769a..f20ed4879c61 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -1,4 +1,7 @@ -import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' +import { + AGENT_JOURNAL_ITEM_BODY_KINDS, + isAdmissibleAgentJournalItemBody +} from '../../../shared/agent-session-journal-schemas' import { AGENT_JOURNAL_TURN_LIFECYCLE_STATES, AGENT_JOURNAL_TURN_OUTCOMES, @@ -9,9 +12,21 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] +function hasKnownKind(body: StoredBody): body is AgentJournalItemBody { + return AGENT_JOURNAL_ITEM_BODY_KINDS.has(body.kind) +} + /** A row this build cannot place stays visible as a row, never invented turn or prompt state — - * and never as its stored JSON, which is Orca's record, not something a person reads. */ -export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { + * and never as its stored JSON, which is Orca's record, not something a person reads. An item of + * a kind this build does not know is carried as it was: kept by its place, as every other row. */ +export function restoreRewindJournalBody(stored: StoredBody): AgentJournalItemBody { + if (!hasKnownKind(stored)) { + if (!isAdmissibleAgentJournalItemBody(stored)) { + throw new Error('agent_session_rewind:invalid-retained-body') + } + return stored + } + const body = stored let normalized: unknown = body // A placeholder, not a failure anyone can act on, so it carries no fact. const fallback = () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts index 5b47be7f9f87..43a2beee24c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -96,6 +96,30 @@ describe('rewind keeps each retained row attributed', () => { expect(merged.at(-1)?.turnScope).toEqual({ kind: 'turn', turnItemId: commandTurn }) }) + it("keeps a newer Orca's row after a row the provider still holds, whatever that row's kind", () => { + // A kind this build does not know, which the provider now returns in its own words. + const future: AgentJournalItemBody = JSON.parse('{"kind":"future-item"}') + const newer: AgentJournalItemBody = JSON.parse('{"kind":"plan-card","n":4}') + const merged = mergeRetainedHostLifecycleRows( + [ + retained(codexKey('turn-a', 2), prose('ok')), + retained(codexKey('turn-a', 9), future), + retained(orcaKey('u4'), newer) + ], + [ + providerItem(codexKey('turn-a', 2), prose('ok')), + providerItem(codexKey('turn-a', 9), prose('provider now renders it')) + ] + ) + + expect(merged.map((row) => row.itemId)).toEqual([ + codexKey('turn-a', 2), + codexKey('turn-a', 9), + orcaKey('u4') + ]) + expect(merged[2]?.body).toEqual(newer) + }) + it('places a provider item the old epoch never held in the turn record for its provider turn', () => { const commandTurn = orcaKey('command-turn:cmd-1') const reference = [ diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index 6496518b50bd..f3e725787097 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,10 +1,12 @@ // Provider preflight returns provider items only. The host's lifecycle rows are its own record, so // a rewind that takes the provider list as the new epoch must splice those rows back beside the -// provider item each one followed. Provider items carry neither turn scope nor producer, so each -// keeps the ones its retained row held. +// provider item each one followed; so is an item of a kind a newer Orca wrote, which no provider +// holds. Provider items carry neither turn scope nor producer, so each keeps the ones its retained +// row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_ITEM_BODY_KINDS } from '../../../shared/agent-session-journal-schemas' import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -28,7 +30,7 @@ export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { ) } -/** `reference` fixes where each host row sits; provider items are the ordered spine. */ +/** `reference` fixes where each carried row sits; provider items are the ordered spine. */ export function mergeRetainedHostLifecycleRows( reference: readonly RetainedRow[], providerItems: readonly RetainedRow[] @@ -38,9 +40,14 @@ export function mergeRetainedHostLifecycleRows( const rowsAfter = new Map() let anchor = -1 for (const item of reference) { - if (!isRetainedHostLifecycleRow(item)) { - anchor = spineIndex.get(item.itemId) ?? anchor - } else if (!spineIndex.has(item.itemId)) { + // Any row the provider still holds moves the anchor, carried or not, so a later row follows it. + const spine = spineIndex.get(item.itemId) + if (spine !== undefined) { + anchor = spine + } else if ( + isRetainedHostLifecycleRow(item) || + !AGENT_JOURNAL_ITEM_BODY_KINDS.has(item.body.kind) + ) { rowsAfter.set(anchor, [...(rowsAfter.get(anchor) ?? []), item]) } } diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index a5d1d899723a..9deb2e20fe67 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -211,3 +211,20 @@ export function shouldReplaceMarker( // regress a newer witness from another host. return incoming.teardownId === current.teardownId } + +/** Records "dismiss all" leaves as they were: this host does not list them (a newer Orca's chats). */ +export type KeepRecord = (marker: AgentSessionResumeMarker) => boolean + +/** What a "dismiss all" keeps, and how many pending offers it ends. */ +export function splitDismissedAll( + state: Pick, + keep: KeepRecord +): { kept: Pick; dismissedPending: number } { + const entries = state.entries.filter((entry) => keep(entry.marker)) + return { + kept: { entries, failed: state.failed.filter((failure) => keep(failure.marker)) }, + dismissedPending: state.entries.filter( + (entry) => entry.state === 'pending' && !keep(entry.marker) + ).length + } +} diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 80ed0bb726e2..54b579a1caff 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -15,8 +15,10 @@ import { normalizeState, parseState, shouldReplaceMarker, + splitDismissedAll, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, + type KeepRecord, type RecoveryCapsuleState, type RecoveryEntry } from './agent-session-recovery-capsule-entries' @@ -277,23 +279,24 @@ export class AgentSessionRecoveryCapsule { }) } - clearAll(now: number): Promise { + /** `keep` names records this host does not list; they stay for the Orca that can act on them. */ + clearAll(now: number, keep: KeepRecord = () => false): Promise { return withFileTransactionLock(this.filePath, async () => { - let entries: RecoveryEntry[] + let state: Pick try { - entries = normalizeState(await this.readState(), now).entries + state = normalizeState(await this.readState(), now) } catch { // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. await this.publish({ entries: [], failed: [] }, now, now) return 0 } - const pending = entries.filter((entry) => entry.state === 'pending') - // Dismiss is the explicit user request to forget every recovery record. An in-flight - // action may still finish, but its later complete/rollback becomes a no-op and cannot - // resurrect a row the user dismissed. - await this.publish({ entries: [], failed: [] }, now, now) - return pending.length + const { kept, dismissedPending } = splitDismissedAll(state, keep) + // Dismiss is the explicit user request to forget every recovery record it was shown. An + // in-flight action may still finish, but its later complete/rollback becomes a no-op and + // cannot resurrect a row the user dismissed. + await this.publish(kept, now, now) + return dismissedPending }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index d66ef3d2fa18..20abf16a69cd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -30,6 +30,7 @@ import { closeStructuredAgentSessionChild } from '../../structured-agent-session import { discardStructuredWorkerSession } from './orchestration-structured-worker-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { + liveTestJournalRows, openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' @@ -312,19 +313,34 @@ describe('the accessor', () => { expect(logged()).toEqual([denied, exhausted]) }) - it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { + it('refuses a damaged journal as unloadable at subscribe and send, and keeps its rows (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() - // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. updateTestJournalRowJson(openTestJournalHostDatabase(rig.root).db, SESSION, 2, '}{') await rig.restart() setStructuredAgentSessionHost(rig.host) const frames = await call('agentSession.subscribe', { sessionId: SESSION }) - expect(frames.some((frame) => !frame.ok)).toBe(false) - expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + expect(frames.find((frame) => !frame.ok)).toMatchObject({ + error: { + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + } + }) const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence - expect((await rig.host.send(CALLER, restTestSend('after the repair', fence))).ok).toBe(true) + expect(await rig.host.send(CALLER, restTestSend('after the damage', fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } + }) + expect( + liveTestJournalRows(openTestJournalHostDatabase(rig.root).db, SESSION).find( + (row) => row.seq === 2 + )?.rowJson + ).toBe('}{') }) it('subscribes an old mobile client that holds first even when no agent can start (P2-06)', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 903f8d440afb..10b5610e3531 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -56,7 +56,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ agent: revealed.agent, activate: true }) - return { ok: true as const, ...revealed } + // Named fields only: the host's reasons a journal did not open stay on the host. + const { sessionId, workspaceId, agent, readable } = revealed + return { ok: true as const, sessionId, workspaceId, agent, readable } } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a09e82464ba3..227654cb0221 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -101,11 +101,11 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. journal: { - isReadOnly: false, cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, - snapshot: () => ({ items: STATUS_ITEMS }) + snapshot: () => ({ items: STATUS_ITEMS, submissions: [] }) } as unknown as AgentSessionJournal, params: { location: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 4c7fe2103d7c..9fe1f2083bb1 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -526,7 +526,7 @@ describe('a create that reserves its tab', () => { }) describe('a chat tab over records a newer Orca wrote', () => { - it('opens a closed chat from history for reading', async () => { + it("opens a closed chat's tab from history, whose chat does not load", async () => { await createChat(HOST_TEST_SESSION) await host.close(HOST_TEST_SESSION, 'user-close') await host.setSessionTabVisibility(HOST_TEST_SESSION, false) @@ -538,7 +538,7 @@ describe('a chat tab over records a newer Orca wrote', () => { expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ ok: true, - result: { ok: true, readable: true } + result: { ok: true, readable: false } }) expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 9a8d9edb96f1..14efe114f903 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -209,7 +209,7 @@ describe('host collaborators log with their own scope', () => { it('the queued-message drain logs a step that fails', async () => { const log = recordingStructuredAgentSessionLogger() - const journal = { isReadOnly: false, queuedMessages: { settlementOwed: () => true } } + const journal = { queuedMessages: { settlementOwed: () => true } } const drain = new StructuredAgentSessionQueuedMessageDrain( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. { diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index b4d0399d10da..9926aaba2dce 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -7,9 +7,9 @@ // does and pushes the same notifications and blocking requests back. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type * as JournalStoreFactory from '../native-chat/agent-session-journal/journal-store-factory' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -22,6 +22,32 @@ import { type StructuredCodexRpcHarness } from './structured-codex-session-rpc-test-harness' +const { journalOpenHold } = vi.hoisted(() => { + const journalOpenHold: { next: { entered: () => void; released: Promise } | null } = { + next: null + } + return { journalOpenHold } +}) + +// Holds the next journal open midway, so provider events land while the attach binds its journal. +vi.mock('../native-chat/agent-session-journal/journal-store-factory', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + openAgentSessionJournal: async ( + input: Parameters[0] + ) => { + const hold = journalOpenHold.next + journalOpenHold.next = null + if (hold) { + hold.entered() + await hold.released + } + return actual.openAgentSessionJournal(input) + } + } +}) + const journals = createTrackedJournalOpener() const WORKSPACE = 'workspace-1' @@ -72,23 +98,16 @@ describe('a structured codex session over agentSession.*', () => { }) it('joins an acquired attach through journal bind before draining final rows', async () => { - const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) - const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps - .adapter - const historyEntered = Promise.withResolvers() - const historyGate = Promise.withResolvers() - const originalHistoryFilePath = adapter.historyFilePath.bind(adapter) - vi.spyOn(adapter, 'historyFilePath').mockImplementation(async (input) => { - historyEntered.resolve() - await historyGate.promise - return originalHistoryFilePath(input) - }) + await ensureStructuredAgentSessionHost(harness.hostConfig()) + const openEntered = Promise.withResolvers() + const openGate = Promise.withResolvers() + journalOpenHold.next = { entered: openEntered.resolve, released: openGate.promise } const creating = harness.ok<{ fence: number }>( 'agentSession.create', harness.createIntentParams() ) - await historyEntered.promise + await openEntered.promise harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) harness.codex.notify('item/started', { threadId: THREAD, @@ -108,7 +127,7 @@ describe('a structured codex session over agentSession.*', () => { }) await new Promise((resolve) => setImmediate(resolve)) const waitedForJournalBind = !stopped - historyGate.resolve() + openGate.resolve() await creating await stopping expect(waitedForJournalBind).toBe(true) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index ac6df4c4b2b3..e0bfa5bbdd3f 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -14,7 +14,10 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + closeTestJournalHostDatabases, + SAVED_BY_NEWER_ORCA +} from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' @@ -269,7 +272,7 @@ describe('restoring the chat tabs open at quit', () => { { store: 'records a newer Orca wrote', newer: true, writesFail: false }, { store: 'a store whose writes keep failing', newer: false, writesFail: true } ])( - 'lists and reads every chat from $store, and writes nothing', + 'lists every chat from $store and writes nothing; it reads them unless a newer Orca saved them', async ({ newer, writesFail }) => { const records = [ chatRecord(CHAT_A), @@ -298,12 +301,17 @@ describe('restoring the chat tabs open at quit', () => { `agent-session:${CHAT_B}` ]) expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) - await expectHistory(CHAT_A) - await expectHistory(CHAT_B) - // A newer Orca's leases are adjudicated in memory, so nothing fails there. + // A newer Orca's leases are adjudicated in memory, so nothing fails there; its chats do not open. if (newer) { - expect(log.entries).toEqual([]) + for (const sessionId of [CHAT_A, CHAT_B]) { + await expect( + getStructuredAgentSessionHost()!.journalSnapshot(sessionId) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + } + expect(log.entries.map((entry) => entry.fields.scope)).not.toContain('lease-reconcile') } else { + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) expect(log.entries).toEqual([ expect.objectContaining({ fields: { diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx index 150b6452921f..8e50bce5e9f2 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -7,6 +7,77 @@ import { NativeChatApprovalCard } from './NativeChatApprovalCard' afterEach(cleanup) describe('NativeChatApprovalCard', () => { + describe("a subject of a kind this build cannot draw: a newer Orca's", () => { + // A subject kind a newer build wrote; this build draws only plans. + const NEWER_SUBJECT = JSON.parse('{"kind":"diff","path":"a.ts"}') + const NEEDS_NEWER_ORCA = 'This request needs a newer version of Orca.' + + function renderNewer(detail: string | undefined) { + const onChoose = vi.fn() + const onCancel = vi.fn() + render( + + ) + return { onChoose, onCancel } + } + + it('with no detail: says so, answers nothing, and only its cancel reaches the host', () => { + const { onChoose, onCancel } = renderNewer(undefined) + expect(screen.getByText(NEEDS_NEWER_ORCA)).toBeTruthy() + for (const label of ['Approve', 'Deny']) { + const option = screen.getByRole('button', { name: label }) + expect(option.hasAttribute('disabled')).toBe(true) + fireEvent.click(option) + } + expect(onChoose).not.toHaveBeenCalled() + const cancel = screen.getByRole('button', { name: 'Cancel' }) + expect(cancel.hasAttribute('disabled')).toBe(false) + fireEvent.click(cancel) + fireEvent.keyDown(screen.getByRole('group'), { key: 'Escape' }) + expect(onCancel).toHaveBeenCalledTimes(2) + }) + + it('with a detail: shows it, and still approves nothing', () => { + const { onChoose } = renderNewer('# Release\n- Run tests') + expect(document.querySelector('[data-native-chat-approval-detail]')?.textContent).toContain( + '# Release' + ) + expect(screen.getByText(NEEDS_NEWER_ORCA)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Approve' })) + expect(screen.getByRole('button', { name: 'Approve' }).hasAttribute('disabled')).toBe(true) + expect(onChoose).not.toHaveBeenCalled() + }) + + it('leaves a plan answerable, with no such line', () => { + const onChoose = vi.fn() + render( + + ) + expect(screen.queryByText(NEEDS_NEWER_ORCA)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Approve plan' })) + expect(onChoose).toHaveBeenCalledWith('allow') + }) + }) + it('exposes cancellation while it owns the composer region', () => { const onCancel = vi.fn() diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 99007d666a32..8ab0d6d822cd 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -5,6 +5,10 @@ import { translate } from '@/i18n/i18n' import CommentMarkdown, { type CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { + isNewerApprovalSubject, + isPlanApprovalSubject +} from '../../../../shared/agent-session-approval-subject' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import type { ChatApproval } from './native-chat-interactive-prompt' @@ -34,6 +38,8 @@ export function NativeChatApprovalCard({ allowFileUriLinks = false }: NativeChatApprovalCardProps): React.JSX.Element { const cardRef = useRef(null) + // A newer Orca's subject: its detail is shown, and only the card's cancel answers. + const newerSubject = isNewerApprovalSubject(approval.subject) const hasContext = Boolean( approval.description || approval.decisionReason || @@ -121,7 +127,7 @@ export function NativeChatApprovalCard({

) : null} - {approval.subject?.kind === 'plan' ? ( + {isPlanApprovalSubject(approval.subject) ? (
) : null} + {newerSubject ? ( +

+ {translate( + 'components.native-chat.approval.needsNewerOrca', + 'This request needs a newer version of Orca.' + )} +

+ ) : null}
) : null}
@@ -156,9 +170,10 @@ export function NativeChatApprovalCard({
- - {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} - { - composerRef.current?.focus() - }} - /> - - {!prompt && controller.threadGoal?.goal ? ( - void controller.threadGoal?.change(change)} - /> - ) : null} - {/* Prompt cards take the composer's slot, below the background-task dock. */} - {prompt && approval ? ( - void controller.respond(prompt, { kind: 'option', optionId })} - onCancel={cancelPrompt} - shouldFocus={props.isVisible && props.isFocusedGroup} - onLinkClick={onLinkClick} - allowFileUriLinks={onLinkClick !== undefined} - /> - ) : null} - {prompt && questionBody ? ( - ({ - question: question.question, - ...(question.header ? { header: question.header } : {}), - multiSelect: question.multiSelect, - options: question.options.map((option) => ({ - label: option.label, - ...(option.description ? { description: option.description } : {}) - })) - })) - }} - allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} - onAnswer={(answers) => { - const chosen = questions.map((question, questionIndex) => { - const answer = answers[questionIndex] - const other = answer?.other?.trim() - const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { - const optionId = question.options[optionIndex]?.id - return optionId ? [optionId] : [] - }) - return { questionId: question.id, optionIds, ...(other ? { other } : {}) } - }) - if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { - void controller.respond(prompt, { kind: 'answers', answers: chosen }) + {readFailedFinally ? null : ( + <> + + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} + { + composerRef.current?.focus() + }} + /> + - ) : null} - {prompt ? null : ( - void controller.stop()} - steerQueued={controller.queuedMessages.steerNewest} - structuredTransport={structuredTransport} - launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} - /> + reconnecting={viewState.kind !== 'error' && readFailure !== null && !readFailure.named} + composerError={composerError} + isVisible={props.isVisible} + backgroundTasks={controller.backgroundTasks} + stopBackgroundTask={controller.stopBackgroundTask} + /> + {!prompt && controller.threadGoal?.goal ? ( + void controller.threadGoal?.change(change)} + /> + ) : null} + {/* Prompt cards take the composer's slot, below the background-task dock. */} + {prompt && approval ? ( + void controller.respond(prompt, { kind: 'option', optionId })} + onCancel={cancelPrompt} + shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} + onLinkClick={onLinkClick} + allowFileUriLinks={onLinkClick !== undefined} + /> + ) : null} + {prompt && questionBody ? ( + ({ + question: question.question, + ...(question.header ? { header: question.header } : {}), + multiSelect: question.multiSelect, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}) + })) + })) + }} + allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} + onAnswer={(answers) => { + const chosen = questions.map((question, questionIndex) => { + const answer = answers[questionIndex] + const other = answer?.other?.trim() + const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { + const optionId = question.options[optionIndex]?.id + return optionId ? [optionId] : [] + }) + return { questionId: question.id, optionIds, ...(other ? { other } : {}) } + }) + if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { + void controller.respond(prompt, { kind: 'answers', answers: chosen }) + } + }} + onCancel={cancelPrompt} + /> + ) : null} + {composerShown ? ( + void controller.stop()} + steerQueued={controller.queuedMessages.steerNewest} + structuredTransport={structuredTransport} + launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} + /> + ) : null} + )} {paneCommands.menu} diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 462c63182b3f..706777fe645f 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -58,6 +58,10 @@ const SENTENCES: Record string> = { 'components.native-chat.writeNotice.updateOrcaToKeepUsing', COPY.updateOrcaToKeepUsing ), + chatSavedByNewerOrca: () => + translate('components.native-chat.writeNotice.chatSavedByNewerOrca', COPY.chatSavedByNewerOrca), + updateOrcaToOpenChat: () => + translate('components.native-chat.writeNotice.updateOrcaToOpenChat', COPY.updateOrcaToOpenChat), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), notAvailable: () => translate('components.native-chat.writeNotice.notAvailable', COPY.notAvailable), diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index 89d8de5d0119..98051945865b 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -20,8 +20,9 @@ export type NativeChatViewState = * Decide which surface to render. Any renderable message wins over loading/empty so optimistic * first sends never get replaced while transcript discovery catches up. Where the read retries on * its own (the structured chat), messages win over an error too: a read that fails after the - * transcript loaded keeps it, and the error reaches the composer's error line. The terminal-backed - * read does not retry, and its only messages on error are local echoes, so its error takes the pane. + * transcript loaded keeps it, and the error reaches the composer's error line. A read that does not + * retry takes the pane: the terminal-backed one, whose only messages on error are local echoes, and + * a structured read that failed for good. */ export function selectNativeChatViewState( session: NativeChatSession, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index eaee915ac685..3424b39dc3cd 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -316,45 +316,49 @@ describe('structured agent-session read transport unattached refusals', () => { } }) - it('hands the pane the refusal a read met, and stops reconnecting only past damage', async () => { - vi.useFakeTimers() - try { - const journalRefusal = (reason: string) => ({ - code: 'runtime_error', - message: 'agent_session_journal_unreadable', - data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } - }) - const applyError = vi.fn() - const transport = startWithHydration(async () => undefined, applyError) - await flushPromises() - expect(attempts).toHaveLength(1) + it.each(['journalCorrupt', 'journalWrittenByNewerOrca'])( + 'hands the pane the refusal a read met, and stops reconnecting only past %s', + async (final) => { + vi.useFakeTimers() + try { + const journalRefusal = (reason: string) => ({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + }) + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) - // An open that can clear keeps reconnecting. - attempts[0].onError(journalRefusal('journalUnavailable')) - attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) - await flushPromises() - expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalUnavailable' } - }) - await vi.advanceTimersByTimeAsync(750) - expect(attempts).toHaveLength(2) + // An open that can clear keeps reconnecting. + attempts[0].onError(journalRefusal('journalUnavailable')) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) - // Damage no retry reads past: decided from the reason, not the message, which is the same. - attempts[1].onError(journalRefusal('journalCorrupt')) - attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) - await flushPromises() - expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalCorrupt' } - }) - await vi.advanceTimersByTimeAsync(60_000) - expect(attempts).toHaveLength(2) - transport.dispose() - } finally { - vi.useRealTimers() + // Damage, or a newer Orca's chat, no retry reads past: decided from the reason, not the + // message, which is the same. + attempts[1].onError(journalRefusal(final)) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: final } + }) + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toHaveLength(2) + transport.dispose() + } finally { + vi.useRealTimers() + } } - }) + ) it('reads a thrown hydrate refusal the same way, and a new run reads again', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index ebfd23060bbf..bef56bf7f81e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -158,6 +158,46 @@ describe('against a capable host', () => { }) }) + // The host's queue would hold a send behind a prompt nothing here can settle. + it('sends immediately while every pending prompt is one this build cannot answer', () => { + const approval = (subject: Record): AgentJournalRenderItem => + JSON.parse( + JSON.stringify({ + itemId: `approval-${String(subject.kind)}`, + revision: 1, + sequence: 2, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review', + detail: null, + subject, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + }) + ) + const newer = approval({ kind: 'diff', path: 'a.ts' }) + items = [newer] + render() + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: false + }) + mocks.outboxArgs.length = 0 + items = [newer, approval({ kind: 'plan', text: 'do it' })] + render() + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: true + }) + }) + it('Stop is a plain cancel: drafts stay as cards and no text lands in the composer', async () => { queuedMessages = [{ ...draft('draft-1'), paused: true }] const { result } = render(false) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 0f126fe0559c..6d73bc3ce050 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -38,6 +38,7 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' @@ -117,6 +118,14 @@ export function useStructuredAgentSession(args: { () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] ) + const prompts = pendingStructuredSessionPrompts(transportState.journalItems) + // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot + // answer: the send must start a turn, after which the card's cancel works. + const queueEnabled = queueFollowUps && !pendingPromptsAllUnanswerableHere(prompts) + const queueDelivery = useMemo( + () => ({ capability: queueCapability, enabled: queueEnabled }), + [queueCapability, queueEnabled] + ) const outboxController = useStructuredAgentSessionOutbox({ sessionId, target, @@ -124,7 +133,7 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, journalItems: transportState.journalItems, composerScopeKey, - queueDelivery: { capability: queueCapability, enabled: queueFollowUps }, + queueDelivery, queuedMessageIds }) @@ -145,7 +154,6 @@ export function useStructuredAgentSession(args: { enabled: providerVisible }) - const prompts = pendingStructuredSessionPrompts(transportState.journalItems) const { outbox } = outboxController // A host that takes a Stop naming no turn gets Stop from the send until the work settles; every // Stop before a turn opens needs that form. An older host can stop only a turn it has opened. @@ -159,12 +167,8 @@ export function useStructuredAgentSession(args: { // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking const transcriptOutbox = useMemo( - () => - outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, { - capability: queueCapability, - enabled: queueFollowUps - }), - [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] + () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery), + [isWorking, outbox, queueDelivery, queuedMessageIds] ) // What the transcript reads: the journal plus the one notice a cut turn with no row gets. const transcriptItems = useMemo( diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index cc4ab94202a5..3cc1ed29e599 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2863,6 +2863,7 @@ "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "Orca can't find this chat.", + "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", "conversationCleared": "This conversation has been cleared.", @@ -2905,6 +2906,7 @@ "unreachable": "Orca couldn't reach the agent.", "unsupported": "This needs a newer Orca on the computer running this chat. Update Orca there, then try again.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", + "updateOrcaToOpenChat": "Update Orca to open it.", "waitForBackgroundTasks": "Wait for the background tasks to finish.", "waitForCommand": "Wait for the /compact or /clear to finish.", "waitForStart": "Wait for the agent to finish starting.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4caf882e7da9..b2e1b2953783 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17782,6 +17782,8 @@ "historyUnavailable": "Orca couldn't open this chat's history right now.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", + "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", + "updateOrcaToOpenChat": "Update Orca to open it.", "unsupported": "This needs a newer Orca on the computer running this chat. Update Orca there, then try again.", "notAvailable": "This isn't available in this chat.", "unreachable": "Orca couldn't reach the agent.", @@ -18101,6 +18103,7 @@ "reason": "Reason", "blockedPath": "Blocked path", "askRule": "Ask rule", + "needsNewerOrca": "This request needs a newer version of Orca.", "plan": { "file": "Plan file" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4f002551643a..3b0588cae3de 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17565,6 +17565,8 @@ "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "savedByNewerOrca": "Los chats se guardaron con una versión más reciente de Orca.", "updateOrcaToKeepUsing": "Actualice Orca para seguir usándolos.", + "chatSavedByNewerOrca": "Este chat se guardó con una versión más reciente de Orca.", + "updateOrcaToOpenChat": "Actualice Orca para abrirlo.", "unsupported": "Esto requiere una versión más reciente de Orca en el equipo que ejecuta este chat. Actualice Orca allí y vuelva a intentarlo.", "notAvailable": "Esto no está disponible en este chat.", "unreachable": "Orca no pudo comunicarse con el agente.", @@ -17789,6 +17791,7 @@ "sending": "Enviando…" }, "approval": { + "needsNewerOrca": "Esta solicitud necesita una versión más reciente de Orca.", "title": "¿Permitir {{value0}}?", "allow": "Permitir", "deny": "Denegar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d4c92d9fa997..64eff0891e40 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17664,6 +17664,8 @@ "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "savedByNewerOrca": "Les chats ont été enregistrés par une version plus récente d'Orca.", "updateOrcaToKeepUsing": "Mettez à jour Orca pour continuer à les utiliser.", + "chatSavedByNewerOrca": "Ce chat a été enregistré par une version plus récente d'Orca.", + "updateOrcaToOpenChat": "Mettez à jour Orca pour l'ouvrir.", "unsupported": "Cela nécessite une version plus récente d'Orca sur l'ordinateur qui exécute ce chat. Mettez Orca à jour sur cet ordinateur, puis réessayez.", "notAvailable": "Ceci n'est pas disponible dans ce chat.", "unreachable": "Orca n'a pas pu joindre l'agent.", @@ -17888,6 +17890,7 @@ "sending": "Envoi…" }, "approval": { + "needsNewerOrca": "Cette demande nécessite une version plus récente d'Orca.", "title": "Autoriser {{value0}} ?", "allow": "Autoriser", "deny": "Refuser", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2a933088bc59..89b2d018e9e8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17600,6 +17600,8 @@ "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", + "chatSavedByNewerOrca": "このチャットは新しいバージョンの Orca で保存されました。", + "updateOrcaToOpenChat": "開くには Orca を更新してください。", "unsupported": "このチャットを実行しているコンピューターに新しいバージョンの Orca が必要です。そのコンピューターで Orca を更新してから、もう一度お試しください。", "notAvailable": "このチャットでは利用できません。", "unreachable": "Orca は Agent に接続できませんでした。", @@ -17824,6 +17826,7 @@ "sending": "送信中…" }, "approval": { + "needsNewerOrca": "このリクエストには新しいバージョンの Orca が必要です。", "title": "{{value0}} を許可しますか?", "allow": "許可する", "deny": "拒否", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 47f096156667..6dc0bbf56feb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17600,6 +17600,8 @@ "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", + "chatSavedByNewerOrca": "이 채팅은 최신 버전의 Orca에서 저장되었습니다.", + "updateOrcaToOpenChat": "열려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 컴퓨터에 최신 버전의 Orca가 필요합니다. 해당 컴퓨터에서 Orca를 업데이트한 후 다시 시도하세요.", "notAvailable": "이 채팅에서는 사용할 수 없습니다.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", @@ -17824,6 +17826,7 @@ "sending": "전송 중…" }, "approval": { + "needsNewerOrca": "이 요청에는 최신 버전의 Orca가 필요합니다.", "title": "{{value0}}을(를) 허용하시겠습니까?", "allow": "허용", "deny": "거부", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 04c5fa3f5c69..ec2c1c66e986 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17565,6 +17565,8 @@ "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", + "chatSavedByNewerOrca": "此聊天由更新版本的 Orca 保存。", + "updateOrcaToOpenChat": "请更新 Orca 以打开它。", "unsupported": "运行此聊天的计算机需要更新版本的 Orca。请在该计算机上更新 Orca 后重试。", "notAvailable": "此聊天中无法使用此功能。", "unreachable": "Orca 无法连接到智能体。", @@ -17789,6 +17791,7 @@ "sending": "正在发送…" }, "approval": { + "needsNewerOrca": "此请求需要更新版本的 Orca。", "title": "允许 {{value0}}?", "allow": "允许", "deny": "拒绝", diff --git a/src/shared/agent-session-approval-subject.test.ts b/src/shared/agent-session-approval-subject.test.ts new file mode 100644 index 000000000000..2ad176e6932b --- /dev/null +++ b/src/shared/agent-session-approval-subject.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest' +import { + DRAWN_APPROVAL_SUBJECT_KINDS, + isNewerApprovalSubject, + pendingPromptsAllUnanswerableHere +} from './agent-session-approval-subject' +import type { AgentJournalItemBody } from './agent-session-journal-types' +import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from './agent-session-journal-schemas' + +it('draws exactly the subject kinds the journal schema knows', () => { + expect([...DRAWN_APPROVAL_SUBJECT_KINDS].sort()).toEqual( + [...AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS].sort() + ) +}) + +it("reads only a subject of a kind it does not draw as a newer build's", () => { + expect(isNewerApprovalSubject(undefined)).toBe(false) + expect(isNewerApprovalSubject({ kind: 'plan' })).toBe(false) + expect(isNewerApprovalSubject({ kind: 'diff' })).toBe(true) +}) + +function pending(body: Record): { body: AgentJournalItemBody } { + return JSON.parse( + JSON.stringify({ + body: { + ...body, + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) +} +const NEWER = pending({ + kind: 'approval', + title: 'Review', + detail: null, + subject: { kind: 'diff' }, + options: [{ id: 'allow', label: 'Approve' }] +}) +const PLAN = pending({ + kind: 'approval', + title: 'Plan', + detail: null, + subject: { kind: 'plan', text: 'do it' }, + options: [{ id: 'allow', label: 'Approve' }] +}) +const QUESTION = pending({ kind: 'question', title: 'Which?', options: [] }) + +it('holds only while every pending prompt is an approval this build cannot answer', () => { + expect(pendingPromptsAllUnanswerableHere([])).toBe(false) + expect(pendingPromptsAllUnanswerableHere([NEWER])).toBe(true) + expect(pendingPromptsAllUnanswerableHere([NEWER, NEWER])).toBe(true) + expect(pendingPromptsAllUnanswerableHere([NEWER, PLAN])).toBe(false) + expect(pendingPromptsAllUnanswerableHere([QUESTION, NEWER])).toBe(false) + const cancelled = JSON.parse( + JSON.stringify(NEWER).replace('"state":"pending"', '"state":"cancelled"') + ) + expect(pendingPromptsAllUnanswerableHere([cancelled, PLAN])).toBe(false) + expect(pendingPromptsAllUnanswerableHere([cancelled])).toBe(false) +}) diff --git a/src/shared/agent-session-approval-subject.ts b/src/shared/agent-session-approval-subject.ts new file mode 100644 index 000000000000..0c6354f83981 --- /dev/null +++ b/src/shared/agent-session-approval-subject.ts @@ -0,0 +1,45 @@ +// An approval subject of a kind this build cannot draw is a newer Orca's. It is carried as it was, +// its card shows the approval's `detail`, and only the card's cancel answers it: nobody approves +// what this build cannot show. On Claude the cancel denies the request and the turn goes on; on +// Codex it ends the turn. + +import type { + AgentJournalApprovalSubject, + AgentJournalItemBody, + AgentJournalPlanApprovalSubject +} from './agent-session-journal-types' + +/** The subject kinds this build draws: the known arms of the journal schema's approval subject. + * A copy, kept equal by a test, so clients that never load the schema module can read it. */ +export const DRAWN_APPROVAL_SUBJECT_KINDS: ReadonlySet = new Set(['plan']) + +export function isPlanApprovalSubject( + subject: AgentJournalApprovalSubject | undefined +): subject is AgentJournalPlanApprovalSubject { + return subject?.kind === 'plan' +} + +export function isNewerApprovalSubject(subject: { kind: string } | undefined): boolean { + return subject !== undefined && !DRAWN_APPROVAL_SUBJECT_KINDS.has(subject.kind) +} + +/** Every pending prompt is an approval this build cannot answer. Nothing here can settle them, so + * the composer stays open and a send starts a turn now instead of queueing behind them; with a + * turn running, the card's cancel works. */ +export function pendingPromptsAllUnanswerableHere( + items: readonly { body: AgentJournalItemBody }[] +): boolean { + let found = false + for (const { body } of items) { + if ( + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) { + if (body.kind === 'question' || !isNewerApprovalSubject(body.subject)) { + return false + } + found = true + } + } + return found +} diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts new file mode 100644 index 000000000000..d5edff6dc856 --- /dev/null +++ b/src/shared/agent-session-journal-body-admission.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + readAgentJournalItemBody, + readAgentJournalMessageBody +} from './agent-session-journal-body-admission' + +const RESOLUTION = { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +const QUESTION_ENTRY = { + id: 'q-1', + question: 'Which lane?', + multiSelect: false, + options: [{ id: 'o-1', label: 'First' }], + freeTextQuestionId: 'q-1-other' +} +const QUESTION = { + kind: 'question', + question: 'Which lane?', + options: [{ id: 'o-1', label: 'First' }], + questions: [QUESTION_ENTRY], + resolution: RESOLUTION +} +const PLAN_APPROVAL = { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: RESOLUTION, + subject: { kind: 'plan', text: 'Step one' } +} +const TURN_STATUS = { + kind: 'status', + text: 'Turn started', + turnLifecycle: { turnId: 't-1', state: 'running', startedAt: 5 } +} +const GOAL = { + objective: 'Ship it', + status: 'active', + tokenBudget: null, + tokensUsed: 1, + timeUsedSeconds: 1, + createdAt: 1, + updatedAt: 1 +} + +describe('a body this build reads', () => { + it.each([ + ['a question', QUESTION], + ['a plan approval', PLAN_APPROVAL], + ['a turn lifecycle', TURN_STATUS], + ['a goal change', { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: GOAL } }], + [ + 'a key it does not know, in a question and an option too', + { + ...QUESTION, + next: 1, + options: [{ id: 'o-1', label: 'First', shortcut: 'f' }], + questions: [{ ...QUESTION_ENTRY, hint: 'Pick one' }] + } + ], + [ + 'a block type it does not know', + { kind: 'message', role: 'assistant', blocks: [{ type: 'chart' }] } + ], + [ + 'a goal state it does not know', + { kind: 'status', text: 'Goal', threadGoal: { state: 'paused' } } + ], + ['a turn state it does not know', { kind: 'turn', turnId: 't', state: 'handed-off' }], + ['a body kind it does not know', { kind: 'plan-card', steps: [{ text: 'by a newer build' }] }], + [ + 'a plan subject kind it does not know', + { ...PLAN_APPROVAL, subject: { kind: 'diff', path: 'a.ts' } } + ] + ])('is readable: %s', (_name, body) => { + expect(readAgentJournalItemBody(body)).toBe('readable') + }) +}) + +describe('anything else that fails: damage', () => { + it.each([ + [ + "a turn's context usage of a kind it does not know, which the row reader drops first", + { kind: 'turn', turnId: 't', state: 'done', contextUsage: { used: { kind: 'measured' } } } + ], + ['a body that is not an object', null], + ['a body that is a list', [{ kind: 'status' }]], + ['no kind', { text: 'x' }], + ['an empty kind', { kind: '' }], + ['a kind of only whitespace', { kind: ' ' }], + ['a block type of only whitespace', { kind: 'message', role: 'user', blocks: [{ type: ' ' }] }], + [ + 'a plan subject kind of only whitespace', + { ...PLAN_APPROVAL, subject: { kind: ' ', text: 'x' } } + ], + ['a kind that is not a string', { kind: 7 }], + [ + 'a nested literal that is not a string', + { ...PLAN_APPROVAL, subject: { kind: 5, text: 'x' } } + ], + ['questions that are not a list', { ...QUESTION, questions: null }], + [ + 'a multi-select flag that is not a boolean', + { ...QUESTION, questions: [{ ...QUESTION_ENTRY, multiSelect: 'yes' }] } + ], + [ + 'a question option with no id', + { ...QUESTION, questions: [{ ...QUESTION_ENTRY, options: [{ label: 'First' }] }] } + ], + ['a free-text question id that is not a string', { ...QUESTION, freeTextQuestionId: 4 }], + ['a turn lifecycle with no turn', { ...TURN_STATUS, turnLifecycle: { state: 'running' } }], + [ + 'a turn lifecycle whose turn is a number', + { ...TURN_STATUS, turnLifecycle: { turnId: 7, state: 'running' } } + ], + [ + 'a goal with no token count', + { + kind: 'status', + text: 'Goal', + threadGoal: { state: 'set', goal: { ...GOAL, tokensUsed: null } } + } + ], + ['a goal change with no goal', { kind: 'status', text: 'Goal', threadGoal: { state: 'set' } }], + ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }], + ['question options that are not a list', { ...QUESTION, options: null }], + ['a question without its resolution', { ...QUESTION, resolution: undefined }], + ['a diff whose patch is broken', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], + ['a turn with no state', { kind: 'turn', turnId: 't' }], + ['a negative turn duration', { kind: 'turn', turnId: 't', state: 'done', durationMs: -1 }], + ['a failure fact with no kind', { kind: 'status', text: 'Failed', failure: { kind: '' } }], + [ + 'a tool call id that is only spaces', + { kind: 'tool-call', name: 'Read', state: 'done', callId: ' ' } + ], + [ + 'a known block missing its text', + { kind: 'message', role: 'user', blocks: [{ type: 'text' }] } + ], + ['a block type that is not a string', { kind: 'message', role: 'user', blocks: [{ type: 5 }] }] + ])('is malformed: %s', (_name, body) => { + expect(readAgentJournalItemBody(body)).toBe('malformed') + }) +}) + +describe("a submission's body", () => { + it('reads a message, a newer kind as unreadable, and another known kind as damage', () => { + expect(readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [] })).toBe( + 'readable' + ) + expect(readAgentJournalMessageBody({ kind: 'voice-note', clip: 'x' })).toBe('unreadable') + expect(readAgentJournalMessageBody({ kind: ' ' })).toBe('malformed') + expect(readAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe('malformed') + expect( + readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [{ type: 'text' }] }) + ).toBe('malformed') + }) +}) diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts new file mode 100644 index 000000000000..42d0b8f63768 --- /dev/null +++ b/src/shared/agent-session-journal-body-admission.ts @@ -0,0 +1,30 @@ +// Reading a persisted body by the extension rule in agent-session-journal-schemas.ts: it parses (a +// body, block, goal state or approval subject of a kind this build does not know included), or it +// is damage. A sent message is the one closed set: a kind this build does not know is a newer +// build's. + +import { + AGENT_JOURNAL_ITEM_BODY_KINDS, + isAdmissibleAgentJournalItemBody, + isAdmissibleAgentJournalMessageBody +} from './agent-session-journal-schemas' +import { isJournalTag } from './agent-session-journal-open-union' + +/** `unreadable` is never damage: the caller keeps the row and fails the load as a newer Orca's + * chat, as for a newer `v`. */ +export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed' + +export function readAgentJournalItemBody(body: unknown): AgentJournalContentVerdict { + return isAdmissibleAgentJournalItemBody(body) ? 'readable' : 'malformed' +} + +/** The same for a submission's body, which only a user message may be. A kind this build does not + * know is a newer build's, never sent again here. */ +export function readAgentJournalMessageBody(body: unknown): AgentJournalContentVerdict { + const verdict = readAgentJournalItemBody(body) + if (verdict !== 'readable' || isAdmissibleAgentJournalMessageBody(body)) { + return verdict + } + const kind = typeof body === 'object' && body !== null && 'kind' in body ? body.kind : undefined + return isJournalTag(kind) && !AGENT_JOURNAL_ITEM_BODY_KINDS.has(kind) ? 'unreadable' : 'malformed' +} diff --git a/src/shared/agent-session-journal-open-union.ts b/src/shared/agent-session-journal-open-union.ts new file mode 100644 index 000000000000..60500173838a --- /dev/null +++ b/src/shared/agent-session-journal-open-union.ts @@ -0,0 +1,32 @@ +// A discriminated union that reads a tag this build does not know instead of failing on it: the +// journal's blocks, goal states, approval subjects and item bodies, so a newer build can add one. + +import { z } from 'zod' + +/** A tag a newer build could have written: a string with something other than whitespace in it. */ +export function isJournalTag(value: unknown): value is string { + return typeof value === 'string' && value.trim() !== '' +} + +/** Tags a discriminated union knows, read off it so the two can never disagree. */ +export function knownTags(union: z.ZodDiscriminatedUnion): ReadonlySet { + const key = union.def.discriminator + return new Set( + union.options.flatMap((option) => { + const tag = option instanceof z.ZodObject ? option.shape[key] : undefined + return tag instanceof z.ZodLiteral ? [...tag.values].map(String) : [] + }) + ) +} + +/** A discriminated union a tag this build does not know still reads through: kept as-is, for a + * reader to skip. A tag of only whitespace is damage, as an empty one is. The catch-all aborts, so + * a known arm's own failure still reaches the reader. */ +export function openDiscriminatedUnion(known: T) { + const key = known.def.discriminator + const tags = knownTags(known) + const unknown = z + .object({ [key]: z.string().regex(/\S/) }) + .refine((value) => !tags.has(value[key] ?? ''), { abort: true }) + return z.union([known, unknown]) +} diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01c..cb5e0e573d7d 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -5,16 +5,29 @@ // guards. These schemas are the single deep validators for that render model: // admission must reject a JSON-valid but structurally wrong item (a question // whose `options` are null, a prompt without its `resolution`) so the row is -// rejected at replay, where a repair can delete it, instead of throwing +// refused at replay, where the chat fails to load, instead of throwing // mid-render. // // Discriminants (`kind`, known block `type`s) are validated deeply. Open string // fields (roles, dispatch/tool states) stay type-checked, never enum-checked, // and unknown object keys pass — a same-version row written by a slightly // newer build must not be misread as malformed (see journal-row-schema.ts). +// +// A newer writer extends a body only by new keys, new kinds of an open union, or a bumped row `v`, +// never by changing a field's type, a bound or a closed set this build checks (the closed sets are +// pinned in journal-closed-sets.test.ts). Open unions (a body's `kind`, a block's `type`, a goal's +// `state`, an approval subject's `kind`) keep a value this build does not know as-is and nobody +// draws it; the chat stays writable. Unreadable context usage is dropped +// (journal-row-unusable-annotations.ts). Any other failure is damage: the chat fails to load and +// nothing is deleted. A new value of an open string (turn, resolution, tool or goal `state`) is +// read as-is too, except that an older build's rewind replaces an unknown turn, tool or resolution +// state with a placeholder row and turns an unknown block into a text block of its raw JSON +// (structured-rewind-journal-body.ts); an unknown body kind is carried as-is. History pages carry +// no row `v`, so older clients see such a value too. It must be safe for every older build. import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' +import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -43,15 +56,6 @@ const ToolMetadata = { webSearchResults: z.array(z.object({ title: z.string(), url: z.string() })).optional() } -const KNOWN_BLOCK_TYPES = new Set([ - 'text', - 'tool-call', - 'tool-result', - 'image-ref', - 'subagent-group', - 'background-task' -]) - /** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */ const ProviderCallId = z .string() @@ -71,7 +75,7 @@ const SubagentEntry = z.object({ /** Renderers select blocks by `type` equality and skip what they cannot draw, * so an unknown block type stays admissible; a known type with a broken * payload does not. */ -const Block = z.union([ +const Block = openDiscriminatedUnion( z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), @@ -122,28 +126,23 @@ const Block = z.union([ startedAt: z.number().optional(), settledAt: z.number().optional() }) - ]), - z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) -]) + ]) +) -const PromptOption = z - .object({ - id: z.string(), - label: z.string(), - description: z.string().optional() - }) - .strict() +const PromptOption = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional() +}) -const Question = z - .object({ - id: z.string(), - question: z.string(), - header: z.string().optional(), - multiSelect: z.boolean(), - options: z.array(PromptOption), - freeTextQuestionId: z.string().optional() - }) - .strict() +const Question = z.object({ + id: z.string(), + question: z.string(), + header: z.string().optional(), + multiSelect: z.boolean(), + options: z.array(PromptOption), + freeTextQuestionId: z.string().optional() +}) const Resolution = z.object({ state: z.string().min(1), @@ -167,11 +166,13 @@ const ApprovalMatchedAskRule = z.object({ ruleContent: z.string().optional() }) -const ApprovalSubject = z.object({ - kind: z.literal('plan'), - text: z.string().min(1), - filePath: z.string().optional() -}) +const KnownApprovalSubject = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('plan'), text: z.string().min(1), filePath: z.string().optional() }) +]) +export const AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS = knownTags(KnownApprovalSubject) +/** Open like blocks; a subject this build cannot draw is never approvable here + * (agent-session-approval-subject.ts). */ +const ApprovalSubject = openDiscriminatedUnion(KnownApprovalSubject) const MessageBody = z.object({ kind: z.literal('message'), @@ -193,13 +194,12 @@ const ThreadGoal = z.object({ }) /** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */ -const ThreadGoalState = z.union([ +const ThreadGoalState = openDiscriminatedUnion( z.discriminatedUnion('state', [ z.object({ state: z.literal('set'), goal: ThreadGoal }), z.object({ state: z.literal('cleared') }) - ]), - z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) -]) + ]) +) /** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row * malformed; the fact reader is where an unplaceable one is dropped. */ @@ -209,7 +209,7 @@ const FailureFact = z.object({ refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() }) -export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ +const KnownItemBody = z.discriminatedUnion('kind', [ MessageBody, z.object({ kind: z.literal('tool-call'), @@ -282,6 +282,10 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ }) ]) +/** Every body kind this build knows; a body of another kind is kept as-is and drawn by nobody. */ +export const AGENT_JOURNAL_ITEM_BODY_KINDS = knownTags(KnownItemBody) +export const AgentJournalItemBodySchema = openDiscriminatedUnion(KnownItemBody) + /** Producer linkage as it rides a render item across the process boundary. * `producerKind` stays an open string for the reason the header gives: a host * that learns a third kind must not make its rows unreadable to this client. */ diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0f..9613afa8a849 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -21,8 +21,8 @@ export { type AgentType } /** Bump only alongside a read-time upcaster in `journal-row-schema.ts`. */ /** v3 introduced the `turn` item. A row without one is still written at v2 so - * an older host keeps reading it; the first v3 row latches that host read-only - * instead of truncating the epoch. */ + * an older host keeps reading it; the first v3 row stops that host writing the chat + * (a released one keeps it read-only, this build fails its load) instead of truncating. */ export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 3 export const AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION = 3 const AGENT_SESSION_JOURNAL_PRE_TURN_SCHEMA_VERSION = 2 @@ -167,12 +167,25 @@ export type AgentJournalApprovalMatchedAskRule = { ruleContent?: string } -export type AgentJournalApprovalSubject = { +export type AgentJournalPlanApprovalSubject = { kind: 'plan' text: string filePath?: string } +declare const agentJournalUnknownKind: unique symbol +/** A kind tag this build does not know. Branded, so it never stands in for a known tag. */ +export type AgentJournalUnknownKind = string & { readonly [agentJournalUnknownKind]: true } + +/** A subject of a kind a newer Orca wrote: carried as it was, with whatever fields it holds, and + * never drawn or approved here. */ +export type AgentJournalUnknownApprovalSubject = { readonly kind: AgentJournalUnknownKind } + +/** Open, as the journal schema reads it: narrow with `isPlanApprovalSubject` before reading it. */ +export type AgentJournalApprovalSubject = + | AgentJournalPlanApprovalSubject + | AgentJournalUnknownApprovalSubject + export type AgentJournalApprovalItem = { kind: 'approval' title: string diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index b60356965c0b..41a9606decae 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -559,7 +559,12 @@ describe('a chat whose history the host could not open', () => { 'send', "Orca couldn't open this chat's history right now. Your message was not sent. Try again." ], - // Only an update gets past it, so it never says to try again. + // Only an update gets past it, so it never says to try again. A read meets it on one chat. + [ + 'journalWrittenByNewerOrca', + 'read-history', + 'This chat was saved by a newer Orca. Update Orca to open it.' + ], [ 'journalWrittenByNewerOrca', 'send', diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 93a1409de030..1cd5a8e9e26c 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -10,22 +10,17 @@ // something beside the words retries it. // Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. -import type { AgentSessionFailureKind } from './agent-session-failure' import { agentSessionFailureSentence, type AgentSessionFailureWordsContext } from './agent-session-failure-words' -import type { AgentSessionRefusalReason } from './agent-session-refusal-details' import { AGENT_SESSION_HISTORY_UNREAD_CAUSES, AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' -import type { - AgentSessionWireRefusal, - AgentSessionWireRefusalCode -} from './agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import { agentSessionRefusalFailure, parseAgentSessionWriteFailure, @@ -33,6 +28,13 @@ import { type AgentSessionWriteKind, type AgentSessionWriteRefusal } from './agent-session-write-failure' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' + +export { + agentSessionRefusalReasonWords, + type AgentSessionRefusalAction, + type AgentSessionRefusalReasonWords +} from './agent-session-refusal-reason-words' const NOT_DONE: Record = { 'read-history': 'notDoneReadHistory', @@ -56,183 +58,6 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } -/** What the person can do about a refusal with this reason. */ -export type AgentSessionRefusalAction = - /** Use the control that sent the write again. */ - | 'retry' - /** Wait for what the notice names; its step says what. */ - | 'wait' - /** Take the step the notice names first. */ - | 'actFirst' - /** Continue somewhere else: the current conversation, or a new chat. */ - | 'goElsewhere' - | 'checkChat' - | 'updateOrca' - /** What the write was for is over: the question it answered moved on. */ - | 'nothingLeft' - /** Orca's own state or fault; nothing the person does gets past it. */ - | 'hostFinding' - -export type AgentSessionRefusalReasonWords = - /** The code's own words are the honest ones for this reason. */ - | { words: 'code'; action: AgentSessionRefusalAction } - /** What stopped the write, and the step past it where the person has one to take. */ - | { - cause: AgentSessionWriteNoticeSentence - step?: AgentSessionWriteNoticeSentence - action: AgentSessionRefusalAction - } - /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ - | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } - -function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { - return { words: 'code', action } -} - -function causeWords( - cause: AgentSessionWriteNoticeSentence, - action: AgentSessionRefusalAction, - step?: AgentSessionWriteNoticeSentence -): AgentSessionRefusalReasonWords { - return step ? { cause, step, action } : { cause, action } -} - -const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') -const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') -// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. -const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') - -// Every reason of every code, so a reason the host adds does not compile until it has words. -// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep -// their code's words. -const REASON_WORDS = { - agent_session_operation_invalid: { - requestMalformed: codeWords('hostFinding'), - operationIdInvalid: codeWords('hostFinding'), - messageIdReused: codeWords('hostFinding'), - // Settled under that id, so the control's retry goes out under a new one. - operationRefusedEarlier: codeWords('retry'), - journalWriteFailed: causeWords('recordFailed', 'retry'), - conversationCleared: causeWords( - 'conversationCleared', - 'goElsewhere', - 'openCurrentConversation' - ), - // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. - clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), - // Only an older host sends this, for a /clear it never settled; only that host resolves it. - conversationCommandUnconfirmed: codeWords('hostFinding'), - conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), - // The chat's agent process is being replaced, which a start or restart does. - handoffInFlight: AGENT_STARTING, - turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), - promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), - backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), - messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), - // No chat surface sends a rewind; a replayed one says only that it did not happen. - rewindRefused: codeWords('hostFinding'), - rewindUnconfirmed: codeWords('hostFinding'), - promptGone: causeWords('questionChanged', 'nothingLeft'), - optionRejected: causeWords('optionRejected', 'retry'), - providerStarting: AGENT_STARTING, - goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), - providerRejected: causeWords('agentRefused', 'retry'), - providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, - notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, - historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, - managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, - accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, - managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, - attachFailed: codeWords('retry') - }, - agent_session_ownership_unknown: { - sessionNotAttached: codeWords('retry'), - noLiveOwner: codeWords('retry'), - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - recordMissing: codeWords('retry'), - replaySuperseded: codeWords('retry'), - leaseMoved: codeWords('retry'), - spawnIdentityMismatch: codeWords('hostFinding'), - notResumable: codeWords('retry'), - noProviderChild: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - // Trying again retries the stop that could not prove the exit. - previousExitUnverifiable: causeWords('ownerUnproven', 'retry') - }, - agent_session_conflict: { - chatStarting: AGENT_STARTING, - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - ownerAlive: codeWords('retry'), - identityMismatch: codeWords('hostFinding'), - sessionExists: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - tabIdTaken: codeWords('retry') - }, - execution_owner_reconciling: { - hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), - recordUnreadable: causeWords('recordUnreadable', 'hostFinding') - }, - agent_session_checkpoint_stale: { - fenceStale: codeWords('retry'), - leaseMoved: codeWords('retry'), - recordMissing: codeWords('retry') - }, - agent_session_identity_required: { - recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), - transcriptNotFound: codeWords('retry'), - transcriptUnreadable: codeWords('hostFinding') - }, - agent_session_operation_conflict: { - fingerprintMismatch: codeWords('hostFinding'), - operationIdReused: codeWords('hostFinding'), - handoffInFlight: codeWords('retry') - }, - agent_session_operation_expired: { operationExpired: codeWords('retry') }, - agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, - agent_session_operation_unknown: { - outcomeUnknown: codeWords('checkChat'), - resultLost: codeWords('checkChat'), - rewindUnconfirmed: codeWords('checkChat'), - tabUnconfirmed: codeWords('checkChat') - }, - agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, - agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, - agent_session_journal_unreadable: { - // No retry reads past damage, and the words name no step: it only can't load. - journalCorrupt: causeWords('historyUnusable', 'hostFinding'), - // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), - journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') - }, - // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, - // which only a method the host doesn't know proves; no reason here means an older Orca. An - // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing - // the capability words this with its own older copy. - structured_agent_session_unsupported: { - clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), - hostDisabled: causeWords('notAvailable', 'hostFinding'), - hostUnsupported: causeWords('notAvailable', 'hostFinding') - }, - agent_session_owner_restart_failed: {} -} satisfies { - [C in AgentSessionWireRefusalCode]: Record< - AgentSessionRefusalReason, - AgentSessionRefusalReasonWords - > -} - -/** The words and next step a reason gets; undefined when the refusal names none. */ -export function agentSessionRefusalReasonWords( - failure: AgentSessionWriteRefusal -): AgentSessionRefusalReasonWords | undefined { - const reason = failure.details?.reason - const byReason: Partial> | undefined = - REASON_WORDS[failure.code] - return reason === undefined ? undefined : byReason?.[reason] -} - /** A cause, and that the request did not happen unless the cause already says so. */ function causeParts( cause: AgentSessionWriteNoticeSentence, @@ -263,10 +88,11 @@ function reasonParts( ? [NOT_DONE[write], { failure: { kind: words.fact }, surface: 'rejection', context }] : undefined } - const said = causeParts(words.cause, write) + const { cause, step } = write === 'read-history' && words.history ? words.history : words + const said = causeParts(cause, write) // A Retry beside the words is the step for a reason whose action is to retry. const retried = context.retryControl && words.action === 'retry' - return words.step && !retried ? [...said, words.step] : said + return step && !retried ? [...said, step] : said } /** What stopped a refused start, for a line that already says the chat did not start and shows its diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts new file mode 100644 index 000000000000..2aaaac158ff2 --- /dev/null +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -0,0 +1,192 @@ +// The words and next step each refusal reason gets, for every code: a reason the host adds does not +// compile until it has words here. + +import type { AgentSessionFailureKind } from './agent-session-failure' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import type { AgentSessionWriteRefusal } from './agent-session-write-failure' + +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + /** Its words when what failed is reading the chat's history. */ + history?: { cause: AgentSessionWriteNoticeSentence; step: AgentSessionWriteNoticeSentence } + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), + // Only an older host sends this, for a /clear it never settled; only that host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry') + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + // Trying again retries the stop that could not prove the exit. + previousExitUnverifiable: causeWords('ownerUnproven', 'retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), + // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + // A write meets it on a newer Orca's database, or from an older host keeping the chat + // read-only; a read meets it on this one chat, which only an update opens. + journalWrittenByNewerOrca: { + ...causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing'), + history: { cause: 'chatSavedByNewerOrca', step: 'updateOrcaToOpenChat' } + } + }, + // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, + // which only a method the host doesn't know proves; no reason here means an older Orca. An + // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing + // the capability words this with its own older copy. + structured_agent_session_unsupported: { + clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), + hostDisabled: causeWords('notAvailable', 'hostFinding'), + hostUnsupported: causeWords('notAvailable', 'hostFinding') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; undefined when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 08fd7a90a60b..0904ecf85519 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -36,6 +36,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { historyUnavailable: "Orca couldn't open this chat's history right now.", savedByNewerOrca: 'Chats were saved by a newer Orca.', updateOrcaToKeepUsing: 'Update Orca to keep using them.', + chatSavedByNewerOrca: 'This chat was saved by a newer Orca.', + updateOrcaToOpenChat: 'Update Orca to open it.', unsupported: 'This needs a newer Orca on the computer running this chat. Update Orca there, then try again.', notAvailable: "This isn't available in this chat.", @@ -88,4 +90,10 @@ export type AgentSessionWriteNoticePart = /** Causes that already say the history can't be read here, so no sentence after them says it * again. */ export const AGENT_SESSION_HISTORY_UNREAD_CAUSES: ReadonlySet = - new Set(['historyUnusable', 'historyUnavailable', 'historyUnreadable', 'savedByNewerOrca']) + new Set([ + 'historyUnusable', + 'historyUnavailable', + 'historyUnreadable', + 'savedByNewerOrca', + 'chatSavedByNewerOrca' + ]) diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts index 74279d7811d6..9312c3d8e5b1 100644 --- a/src/shared/structured-agent-session-read-refusal.ts +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -47,15 +47,17 @@ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { } /** - * A read refusal no retry reads past: SQLite reported the chat's journal damaged. Decided from the - * reason, never the message, which is the bare code for every journal refusal; a journal that - * failed to open for any other reason can clear, so its read keeps reconnecting. + * A read refusal no retry reads past: the chat's history is damaged, as SQLite or its rows show, or + * a newer Orca saved it, which only an update gets past. Decided from the reason, never the + * message, which is the bare code for every journal refusal; a journal that failed to open for any + * other reason can clear, so its read keeps reconnecting. */ export function isFinalAgentSessionReadRefusal( refusal: AgentSessionRefusalReference | undefined ): boolean { return ( refusal?.code === 'agent_session_journal_unreadable' && - refusal.details?.reason === 'journalCorrupt' + (refusal.details?.reason === 'journalCorrupt' || + refusal.details?.reason === 'journalWrittenByNewerOrca') ) } diff --git a/src/shared/structured-agent-session-unknown-item.test.ts b/src/shared/structured-agent-session-unknown-item.test.ts new file mode 100644 index 000000000000..92ec054d1427 --- /dev/null +++ b/src/shared/structured-agent-session-unknown-item.test.ts @@ -0,0 +1,38 @@ +// An item of a kind this build does not know is kept and drawn by nobody: no client renders it, +// and nothing the host or a client decides from items (a running turn, a pending prompt, status) +// reads it. + +import { expect, it } from 'vitest' +import { isAdmissibleAgentJournalItemBody } from './agent-session-journal-schemas' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { activeStructuredAgentSessionTurnId } from './structured-agent-session-live-turn' +import { + projectStructuredAgentSessionStatusSummary, + projectStructuredItemsToNativeChat +} from './structured-agent-session-projection' + +function renderItem(sequence: number, body: unknown): AgentJournalRenderItem { + // Admitted through the catch-all, though the journal's body type names only known kinds. + if (!isAdmissibleAgentJournalItemBody(body)) { + throw new Error('the journal admits this body') + } + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +const ITEMS = [ + renderItem(1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Ship it' }] }), + renderItem(2, { kind: 'turn', turnId: 'turn-1', state: 'completed' }), + renderItem(3, { kind: 'plan-card', state: 'running', resolution: { state: 'pending' } }) +] + +it('draws nothing for an item of a kind this build does not know', () => { + expect(projectStructuredItemsToNativeChat(ITEMS).map((message) => message.id)).toEqual(['item-1']) +}) + +it('takes no turn, prompt or work from it', () => { + expect(activeStructuredAgentSessionTurnId(ITEMS)).toBeNull() + expect(projectStructuredAgentSessionStatusSummary(ITEMS)).toMatchObject({ + status: 'idle', + latestPrompt: 'Ship it' + }) +}) diff --git a/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts new file mode 100644 index 000000000000..42f5856e35c1 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts @@ -0,0 +1,236 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentSessionJournalIdentity +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionRefusalReference } from '../../../src/shared/agent-session-wire-refusals' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { journalOpenRefusal } from '../../../src/main/native-chat/agent-session-journal/journal-open-failure' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * A chat a newer Orca saved now fails its load here with the newer-Orca reason, where this host used + * to serve it read-only (Rule 3: an existing read answers differently). An older client gets that + * refusal instead of pages: it must word it with what it has and never call it damage. And this + * build keeps a body of a kind it does not know (or a plan subject of one), writable, instead of + * deleting from it, which an older host meets when it opens a journal holding such a body. + */ +const SUITE_TIMEOUT_MS = 180_000 +// The last release that does not know the newer-Orca reason: its words fall back to the code's. +const BEFORE_NEWER_REASON_REF = 'v1.4.218' +// A main build that shares this one's host database and schema version, so a downgrade to it opens +// the journal writable. No release tag has that database yet; move to the first one that does. +const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' +const JOURNAL = 'src/main/native-chat/agent-session-journal' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-chat', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +type ReadHistoryParts = (code: string, details?: unknown, context?: unknown) => unknown[] +type NoticeEnglish = (parts: unknown[]) => string +type IsFinal = (refusal: AgentSessionRefusalReference | undefined) => boolean + +let directory: string +const journals = createTrackedJournalOpener() + +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) +}) + +afterAll(async () => { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) +}) + +/** A function the checked-out build exports, typed as the caller calls it. */ +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the checked-out build exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the checked-out build defines; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +const NEWER_BODY_KIND = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } +const NEWER_NESTED_LITERAL = { + kind: 'approval', + title: 'Approve the change?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, + subject: { kind: 'diff', path: 'a.ts' } +} + +/** A row of a kind this build does not know: the journal's open is refused as a newer Orca's. */ +const NEWER_ROW_KIND = { kind: 'future-mark' } + +/** This build's journal of two items, then a row as a newer build wrote it: an item holding + * `body`, or `row` itself: closed. */ +async function journalWithNewerRow( + row: Record = NEWER_ROW_KIND +): Promise<{ rows: string[]; newer: string }> { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'before' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 2 }, + { kind: 'status', text: 'also before' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const seq = journal.cursor().sequence + 1 + const newer = JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: journal.epoch, + seq, + fence: 1, + ts: 2_000, + ...row + }) + await journals.closeAll() + insertTestJournalRowJson( + openTestJournalHostDatabase(directory).db, + IDENTITY.sessionId, + seq, + newer, + 2_000 + ) + return { rows: storedRows(), newer } +} + +function storedRows(): string[] { + const rows = liveTestJournalRows(openTestJournalHostDatabase(directory).db, IDENTITY.sessionId) + closeTestJournalHostDatabase(directory) + return rows.map((row) => row.rowJson) +} + +/** This host's refusal of a newer Orca's chat, as `history` and `subscribe` raise it. */ +async function newerChatRefusal(): Promise { + await journalWithNewerRow() + const failure = await journals.open({ identity: IDENTITY, stateDirectory: directory }).then( + () => null, + (error: unknown) => error + ) + expect(failure).toMatchObject(SAVED_BY_NEWER_ORCA) + await journals.closeAll() + return journalOpenRefusal(failure) +} + +/** How a released client words a read refusal, and whether it stops retrying on it. */ +async function releasedReader(ref: string) { + const checkout = await materializeReleaseCheckout(ref) + const notice = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-refusal-notice.ts' + ) + const readRefusal = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-read-refusal.ts' + ) + const parts = releaseExport(notice, 'agentSessionReadHistoryRefusalParts') + const english = releaseExport(notice, 'agentSessionWriteNoticeEnglish') + const isFinal = releaseExport(readRefusal, 'isFinalAgentSessionReadRefusal') + // The pane's own Retry stands beside the words, as on desktop. + const words = (refusal: Pick) => + english(parts(refusal.code, refusal.details, { retryControl: true })) + return { words, isFinal } +} + +describe('a chat a newer Orca saved, across versions', () => { + it( + 'old client against new host: a release before the reason words it as an unreadable history and keeps retrying', + async () => { + const refusal = await newerChatRefusal() + const released = await releasedReader(BEFORE_NEWER_REASON_REF) + const code = { code: refusal.code } + const damage = { code: refusal.code, details: { reason: 'journalCorrupt' } } + expect(released.words(refusal)).toBe(released.words(code)) + expect(released.words(refusal)).not.toBe(released.words(damage)) + expect(released.isFinal(refusal)).toBe(false) + }, + SUITE_TIMEOUT_MS + ) + + it( + 'old client against new host: the newest release words it as its own, never as damage', + async () => { + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) + const refusal = await newerChatRefusal() + const released = await releasedReader(resolveBaselineReleaseRef()) + const damage = { code: refusal.code, details: { reason: 'journalCorrupt' } } + expect(released.words(refusal)).not.toBe('') + expect(released.words(refusal)).not.toBe(released.words(damage)) + }, + SUITE_TIMEOUT_MS + ) + + // Why a new body kind ships its reader first, or rides a bumped `v`: this build keeps one and + // stays writable, but a build from before deletes the journal from it. Move the pinned build to + // the first release with this rule, and the older build keeps the row too. + it.each([ + ['body kind', NEWER_BODY_KIND], + ['plan subject kind', NEWER_NESTED_LITERAL] + ])( + "this build keeps a newer build's %s and stays writable; a build before it deletes it", + async (_value, body) => { + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) + const { rows, newer } = await journalWithNewerRow({ + kind: 'item', + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body + }) + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(reopened.snapshot().items).toHaveLength(3) + await journals.closeAll() + expect(storedRows()).toEqual(rows) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport< + () => { + open: (options: { + identity: AgentSessionJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise + } + >(support, 'createTrackedJournalOpener')() + try { + await older.open({ identity: IDENTITY, stateDirectory: directory }) + } finally { + await older.closeAll() + } + expect(storedRows()).not.toContain(newer) + }, + SUITE_TIMEOUT_MS + ) +}) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index b31c8f008cd1..3fc06f9d2dc4 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -15,7 +15,8 @@ import { createTrackedJournalOpener, insertTestJournalRowJson, liveTestJournalRows, - openTestJournalHostDatabase + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -217,7 +218,6 @@ test("an older build opens this build's journal writable and appends to it; the // Upgraded again: the older build's row folds, and the person's Stop still pauses the queue. const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(upgraded.isReadOnly).toBe(false) expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ @@ -230,11 +230,12 @@ test("an older build opens this build's journal writable and appends to it; the }, 120_000) // Why a Stop's event cannot have a row kind of its own yet: this build keeps a kind it does not know -// and goes read-only, but a build from before that deletes the journal from it. So a Stop kind ships +// and refuses the load as a newer Orca's chat, but a build from before that deletes the journal from +// it. So a Stop kind ships // its reader first and is written once no supported build lacks that reader, or is written at a // bumped `v`. Move the baseline to the first release with this rule, and the older build keeps the // row too. -test("this build keeps a newer build's row kind and goes read-only; a build before it deletes it", async () => { +test("this build keeps a newer build's row kind and refuses the load; a build before it deletes it", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-newer-kind-downgrade-')) const journals = createTrackedJournalOpener() const newerKinds = () => storedRows(directory).filter((row) => row.includes('"future-mark"')) @@ -262,12 +263,9 @@ test("this build keeps a newer build's row kind and goes read-only; a build befo closeTestJournalHostDatabase(directory) const rowsBefore = storedRows(directory) - const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(reopened.isReadOnly).toBe(true) - expect(reopened.repair).toEqual({ malformedRows: 0 }) await expect( - reopened.appendItem(item(1), { kind: 'status', text: 'after' }, scope) - ).rejects.toMatchObject({ code: 'journal_read_only' }) + journals.open({ identity: IDENTITY, stateDirectory: directory }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) await journals.closeAll() expect(storedRows(directory)).toEqual(rowsBefore) expect(newerKinds()).toEqual([newer])