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({
onChoose(opt.send)}
className={cn(
- 'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ 'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
i === 0
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-background text-foreground hover:bg-accent'
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx
new file mode 100644
index 000000000000..f9f9dc60f7ce
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment happy-dom
+
+// A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, even after the
+// transcript loaded: its words alone, and nothing that could only be refused again.
+
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+
+const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
+ (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
+)
+vi.mock('@/lib/structured-agent-session-launch', () =>
+ moduleFactories.structuredAgentSessionLaunch()
+)
+vi.mock('@/runtime/structured-agent-session-client', () =>
+ moduleFactories.structuredAgentSessionClient()
+)
+vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
+vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale())
+vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
+vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
+vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
+vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
+vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
+vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
+
+import { NativeChatStructuredSession } from './NativeChatStructuredSession'
+
+afterEach(() => {
+ cleanup()
+ localStorage.clear()
+ resetStructuredSessionMocks()
+})
+
+const APPROVAL: AgentJournalRenderItem = JSON.parse(
+ JSON.stringify({
+ itemId: 'approval-item',
+ revision: 3,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'approval',
+ title: 'Run the plan?',
+ detail: null,
+ subject: { kind: 'plan', text: 'x' },
+ options: [{ id: 'allow', label: 'Approve' }],
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ }
+ })
+)
+
+/** A chat with its transcript on screen, a pending approval, a goal, a queued card and a running
+ * background task, whose read then fails with `reason`. */
+function renderLoadedChatThenFailedRead(
+ reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca'
+): void {
+ mocks.status = 'error'
+ mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } }
+ mocks.turnId = 'turn-1'
+ mocks.promptItems = [APPROVAL]
+ mocks.threadGoal = {
+ goal: {
+ objective: 'Ship',
+ status: 'active',
+ tokenBudget: null,
+ tokensUsed: 0,
+ timeUsedSeconds: 1,
+ createdAt: 1,
+ updatedAt: 1
+ },
+ pending: false,
+ change: vi.fn()
+ }
+ mocks.queuedCards = [
+ { messageId: 'draft-1', position: 1, text: 'queued text', state: 'waiting', hold: 'turn' }
+ ]
+ mocks.monitoringBackgroundTasks = true
+ mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }]
+ render(
+
+
+
+ )
+}
+
+it.each([
+ ['journalCorrupt', 'Unable to load this chat.'],
+ ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.']
+] as const)('a %s read after the transcript loaded leaves only its words', (reason, words) => {
+ renderLoadedChatThenFailedRead(reason)
+
+ expect(screen.getAllByText(words)).toHaveLength(1)
+ expect(screen.queryByTestId('message-list')).toBeNull()
+ expect(mocks.composerProps).toBeNull()
+ expect(mocks.approvalCardProps).toBeNull()
+ expect(document.querySelector('[data-native-chat-thread-goal]')).toBeNull()
+ expect(document.querySelector('[data-queued-message-id="draft-1"]')).toBeNull()
+ expect(document.querySelector('[data-native-chat-background-tasks]')).toBeNull()
+})
+
+it('a failed read that can clear keeps the transcript and every control', () => {
+ renderLoadedChatThenFailedRead('journalUnavailable')
+
+ expect(screen.getByTestId('message-list')).toBeTruthy()
+ expect(mocks.approvalCardProps).not.toBeNull()
+ expect(document.querySelector('[data-queued-message-id="draft-1"]')).not.toBeNull()
+ expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull()
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx
new file mode 100644
index 000000000000..f2b015d7fb38
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx
@@ -0,0 +1,123 @@
+// @vitest-environment happy-dom
+
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
+
+const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
+ (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
+)
+
+vi.mock('@/runtime/structured-agent-session-client', () =>
+ moduleFactories.structuredAgentSessionClient()
+)
+vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
+vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale())
+vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
+vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
+vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
+vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
+vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState())
+vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
+vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
+
+import { NativeChatStructuredSession } from './NativeChatStructuredSession'
+
+afterEach(() => {
+ cleanup()
+ localStorage.clear()
+ resetStructuredSessionMocks()
+})
+
+// An approval whose subject a newer Orca wrote, with no detail to show.
+const NEWER_APPROVAL: AgentJournalRenderItem = JSON.parse(
+ JSON.stringify({
+ itemId: 'approval-item',
+ revision: 3,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'approval',
+ title: 'Review proposed change',
+ detail: null,
+ subject: { kind: 'diff', path: 'a.ts' },
+ options: [{ id: 'allow', label: 'Approve' }],
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ }
+ })
+)
+
+it("hands the card a newer Orca's subject in a writable chat, and its cancel goes to the host with the card", () => {
+ mocks.turnId = 'turn-1'
+ mocks.promptItems = [NEWER_APPROVAL]
+ render(
+
+ )
+ expect(mocks.approvalCardProps).toMatchObject({
+ approval: { subject: { kind: 'diff' } }
+ })
+ expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail')
+ mocks.approvalCardProps?.onCancel?.()
+ expect(mocks.cancel).toHaveBeenCalledWith('turn-1', {
+ itemId: 'approval-item',
+ expectedRevision: 3
+ })
+})
+
+// The host's cancel names its turn, so with none running nothing is sent.
+it('sends nothing for the card when no turn is running', () => {
+ mocks.promptItems = [NEWER_APPROVAL]
+ render(
+
+ )
+ mocks.approvalCardProps?.onCancel?.()
+ expect(mocks.cancel).not.toHaveBeenCalled()
+})
+
+function renderSession() {
+ render(
+
+ )
+}
+
+// Nothing here answers it, so a send is the way on: it starts a turn, whose card cancel works.
+it('keeps the composer open and writable beside a card this build cannot answer', () => {
+ mocks.promptItems = [NEWER_APPROVAL]
+ renderSession()
+ expect(mocks.composerProps).not.toBeNull()
+ expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: false })
+})
+
+it('gives a card this build can answer the composer slot', () => {
+ const plan: AgentJournalRenderItem = JSON.parse(
+ JSON.stringify(NEWER_APPROVAL).replace(
+ '{"kind":"diff","path":"a.ts"}',
+ '{"kind":"plan","text":"do it"}'
+ )
+ )
+ mocks.promptItems = [plan]
+ renderSession()
+ expect(mocks.composerProps).toBeNull()
+ expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: true })
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx
index 0a7d0742f196..41d5df9f9cf1 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx
@@ -70,6 +70,8 @@ it('says a damaged history cannot load in one line, without claiming Orca keeps
expect(screen.queryByText('Could not load conversation')).toBeNull()
expect(screen.queryByText(/keeps trying/)).toBeNull()
expect(screen.queryByText(/agent_session_/)).toBeNull()
+ // Nothing to send into: a send would only be refused and say it again.
+ expect(mocks.composerProps).toBeNull()
})
it("names a history that couldn't open right now once, in its one line", () => {
@@ -83,6 +85,8 @@ it("names a history that couldn't open right now once, in its one line", () => {
expect(screen.queryByText('Could not load conversation')).toBeNull()
expect(screen.queryByText(/keeps trying/)).toBeNull()
expect(screen.queryByText(/Try again/)).toBeNull()
+ // It can clear, so the composer stays and a send waits for the read.
+ expect(mocks.composerProps).not.toBeNull()
})
it("says a code's own words that the history didn't load, and nothing under them", () => {
@@ -114,15 +118,19 @@ it('says only that the history did not load for a chat its host cannot run', ()
expect(screen.queryByText(/isn't available|newer Orca/)).toBeNull()
})
-it("says a newer Orca's words alone", () => {
+it('says once that a newer Orca saved the chat and only an update opens it, with no composer', () => {
mocks.status = 'error'
mocks.readRefusal = journalRefusal('journalWrittenByNewerOrca')
mocks.messages = []
renderPane()
- expect(screen.getByText(/^Chats were saved by a newer Orca\./)).toBeTruthy()
+ expect(
+ screen.getAllByText('This chat was saved by a newer Orca. Update Orca to open it.')
+ ).toHaveLength(1)
+ expect(screen.queryByText('Could not load conversation')).toBeNull()
expect(screen.queryByText(/keeps trying/)).toBeNull()
+ expect(mocks.composerProps).toBeNull()
})
it('says only that it is reconnecting, not as an error, when a failure names nothing', () => {
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index e69056836c48..a81019456b56 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -30,6 +30,7 @@ import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-labe
import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner'
import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice'
import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices'
+import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject'
export function NativeChatStructuredSession(
props: Omit
@@ -121,13 +122,16 @@ export function NativeChatStructuredSession(
retry: controller.retry,
agentName: agentLabel
})
- const viewState = selectNativeChatViewState(session, { readRetries: true })
// Nothing reads an unread history, so its pane stays blank beside the Retry line.
const loadingPane = historyPhase === 'unread' ? null :
const readFailure =
controller.status === 'error'
? structuredAgentSessionReadFailureNotice(controller.readRefusal)
: null
+ // A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, whatever was
+ // already on screen: nothing in it can act, and its words say why once.
+ const readFailedFinally = readFailure?.final === true
+ const viewState = selectNativeChatViewState(session, { readRetries: !readFailedFinally })
const fontScale = useNativeChatFontScale(viewState.kind === 'ready')
const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId)
const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions(
@@ -136,6 +140,10 @@ export function NativeChatStructuredSession(
{ sessionId: props.sessionId, isVisible: props.isVisible }
)
const prompt = controller.prompts[0] ?? null
+ // Prompts this build cannot answer leave the composer open: a send starts a turn, whose card
+ // cancel then works.
+ const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts)
+ const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally
const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null
const approval = approvalBody
? {
@@ -166,7 +174,7 @@ export function NativeChatStructuredSession(
composerRef,
isVisible: props.isVisible,
isFocusedGroup: props.isFocusedGroup,
- composerReady: prompt === null
+ composerReady: composerShown
})
const questionBody = prompt?.body.kind === 'question' ? prompt.body : null
const questions = questionBody ? agentSessionPromptQuestions(questionBody) : []
@@ -273,103 +281,110 @@ export function NativeChatStructuredSession(
/>
)}
-
- {/* 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])