diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 642918a992c..bc3b05b8d31 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/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 34ba98f2566..788a5185523 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5216,7 +5216,6 @@ "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 104, "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 41, "src/renderer/src/components/codex-restart-chip.test.tsx": 2063, - "src/renderer/src/components/codex-restart-notice-key.test.ts": 63, "src/renderer/src/components/comment-code-context-state.test.ts": 34, "src/renderer/src/components/comment-reply-target-state.test.ts": 51, "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 1586, @@ -7801,7 +7800,6 @@ "src/renderer/src/lib/codex-account-display-label.test.ts": 48, "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 82, "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 249, - "src/renderer/src/lib/codex-session-restart-route-recheck.test.ts": 1547, "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 1603, "src/renderer/src/lib/codex-session-restart.test.ts": 6689, "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1587, diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index fa480fb7498..8d69c2e1d64 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 92m + + downloads: 93m @@ -15,7 +15,7 @@ downloads downloads - 92m - 92m + 93m + 93m diff --git a/docs/audits/acknowledged-tab-retirement/fixture.test.ts b/docs/audits/acknowledged-tab-retirement/fixture.test.ts index b7eff444c5d..4036c246734 100644 --- a/docs/audits/acknowledged-tab-retirement/fixture.test.ts +++ b/docs/audits/acknowledged-tab-retirement/fixture.test.ts @@ -13,7 +13,7 @@ import { buildWorkspaceSessionPayload } from '../../../src/renderer/src/lib/work import { buildHeadlessMobileSessionTerminalTabs } from '../../../src/main/runtime/mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from '../../../src/main/runtime/runtime-desktop-surface' import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' -import { advanceTerminalTopologyRevision } from '../../../src/main/runtime/workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../../../src/main/persistence/terminal-topology/terminal-topology-membership' import type { ExecutionHostId } from '../../../src/shared/execution-host' class AuditRuntime extends OrcaRuntimeService { diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 3650528d466..d3d6632b68e 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 68e5c21df8a..9d82f254ffc 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 29f3e421981..ab7dda7374e 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 d224c2e34a0..ff2637f8a45 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 469abba5388..8e729e5e46b 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 7ccbc10e219..b5d7959eb46 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 065845640b3..0a682f1c937 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 1f8bdae2d48..c3add0c67b4 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 00000000000..594a84acf5f --- /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 00000000000..0c1c1a7f711 --- /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 b0ecfe9832c..7bec053c9d6 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 9eca412c541..b903e42de00 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 44c94f777a2..613dd14fe08 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 b7aef25e138..1508793d153 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -105,10 +105,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[] = [] @@ -193,8 +193,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: () => ({ @@ -207,7 +207,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 a865e1c1b76..7eb09546e72 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-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 0ade48d5812..9d75db1fca9 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -63,9 +63,7 @@ export function journalClaudeMessage( startsTurn: boolean, observedAt: number, /** Host clock on the submission that produced this send, when known. */ - requestedAt?: number, - /** The submission this send echo acknowledged. */ - openedBy?: string + requestedAt?: number ): boolean { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { @@ -178,7 +176,6 @@ export function journalClaudeMessage( startsTurn, observedAt, ...(requestedAt === undefined ? {} : { requestedAt }), - ...(openedBy === undefined ? {} : { openedBy }), userItemId: agentJournalItemKey(identity) }) if (sendEchoTurn) { diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index ddc1bd55390..1cf0a8d048c 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -60,11 +60,6 @@ export class ClaudeOpenTurn { } /** The open turn's row, where a fact about the running turn lands. */ - /** The submission that opened the open turn, when known (`ClaudeCurrentTurn.openedBy`). */ - get openedBy(): string | null { - return this.current?.openedBy ?? null - } - get identity(): AgentJournalItemIdentity | null { return this.current ? claudeCurrentTurnIdentity(this.current) : null } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index a4226067b98..1473886b9ea 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -59,9 +59,7 @@ export function journalClaudeResult( // Read before the settle below closes the turn: an error end the journal's Stop rule makes a // person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null - const leftToStop = - turnId !== null && - sink.journalStopDecidesTurn?.(turnId, observedAt, turn.openedBy ?? undefined) === true + const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 3442dcc956b..070798bc521 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -179,10 +179,8 @@ export function createClaudeJournalTranslator( message: Record, startsTurn: boolean, observedAt: number, - requestedAt?: number, - openedBy?: string - ): boolean => - journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt, openedBy) + requestedAt?: number + ): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt) return { handle: (event) => { @@ -250,8 +248,7 @@ export function createClaudeJournalTranslator( event.message, event.startsTurn === true, event.observedAt ?? Date.now(), - event.requestedAt, - event.clientMessageId + event.requestedAt ) ) { providerFallback.append( diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index c74e4741fc1..cfb30d7363a 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/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 1d0db5fa21f..e816be7f2b2 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -19,8 +19,6 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string - /** The submission whose send opened the turn, while its echo has yet to land in the journal. */ - openedBy?: string /** Present when the turn is the host's record of a conversation command. */ command?: ClaudeCommandTurn } diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts index b5e13a39c71..1406928b37e 100644 --- a/src/main/claude/claude-turn-opening.ts +++ b/src/main/claude/claude-turn-opening.ts @@ -30,8 +30,6 @@ export type ClaudeSendEchoTurnInput = { requestedAt?: number /** Provider key of the user row this turn is anchored to. */ userItemId: string - /** The submission this echo acknowledged. */ - openedBy?: string } /** The turn a replayed send echo opens, or null when this frame is not one. */ @@ -48,8 +46,7 @@ export function claudeTurnOpenedBySendEcho( turnId: envelope.uuid, startedAt: input.observedAt, ...(input.requestedAt === undefined ? {} : { requestedAt: input.requestedAt }), - userItemId: input.userItemId, - ...(input.openedBy === undefined ? {} : { openedBy: input.openedBy }) + userItemId: input.userItemId } : null } diff --git a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts index 596b70f2c1e..5ea98007cc7 100644 --- a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts +++ b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts @@ -145,14 +145,13 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.isHostSystemDefaultRealHome()).toBe(true) - expect(service.getSelectedHostCodexHomeRoute()).toBe('real-home') expect(service.prepareForCodexLaunch()).toBeNull() expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([ getRuntimeCodexHomePath(), getSystemCodexHomePath() ]) service.setRealHomeLaneGate(() => false) - expect(service.getSelectedHostCodexHomeRoute()).toBe('shared-home') + expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) const markerPath = join( testState.userDataDir, diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts index 18d7549d3f5..04ea9383f2a 100644 --- a/src/main/codex-accounts/runtime-home-service-home-routing.ts +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -16,8 +16,7 @@ import { import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { hasRecordedLegacySharedCodexPane, - getCodexPaneAccount, - type CodexPaneHomeRoute + getCodexPaneAccount } from '../codex/codex-pane-account-registry' import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' @@ -92,13 +91,6 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom : { kind: 'skip' } } - getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { - if (this.getSelfContainedManagedHostAccount()) { - return 'account-home' - } - return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' - } - getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { const settings = this.store.getSettings() const homes = new Map() @@ -107,11 +99,7 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom if (!record || record.selectionKey !== 'host') { continue } - if ( - record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' - ) { + if (record.homeRoute === undefined || record.homeRoute === 'shared-home') { const homePath = this.getRuntimeHomePath() homes.set(normalizeRuntimePathForComparison(homePath), homePath) continue diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts index 472bb28c72c..66c6c229f27 100644 --- a/src/main/codex-accounts/runtime-home-service-state.ts +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -15,7 +15,6 @@ import type { CodexSystemDefaultSnapshot } from './runtime-home-service-types' import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' -import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from './runtime-selection' import type { LegacyWslRuntimeAuthDestination } from './legacy-wsl-runtime-auth-drain' import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' @@ -92,7 +91,6 @@ export abstract class CodexRuntimeHomeState { abstract resolveCodexManagedAccountHomeForInactiveFetch( account: CodexManagedAccount ): { kind: 'ready'; homePath: string } | { kind: 'skip' } - abstract getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute abstract getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] abstract setRealHomeLaneGate(gate: () => boolean): void abstract isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean diff --git a/src/main/codex/codex-close-turn-end.test.ts b/src/main/codex/codex-close-turn-end.test.ts index 1e3475301bf..98ae89a7347 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-pane-account-registry-mutations.ts b/src/main/codex/codex-pane-account-registry-mutations.ts index f946453c0bf..61de741d45f 100644 --- a/src/main/codex/codex-pane-account-registry-mutations.ts +++ b/src/main/codex/codex-pane-account-registry-mutations.ts @@ -137,12 +137,7 @@ function recordsEqual( return ( left?.selectionKey === right.selectionKey && left.accountId === right.accountId && - left.homeRoute === right.homeRoute && - left.shellStartupHomeOverride?.home === right.shellStartupHomeOverride?.home && - left.shellStartupHomeOverride?.shell === right.shellStartupHomeOverride?.shell && - left.shellStartupHomeOverride?.configHome === right.shellStartupHomeOverride?.configHome && - left.shellStartupHomeOverride?.codexHome === right.shellStartupHomeOverride?.codexHome && - left.environmentHomeOverride?.codexHome === right.environmentHomeOverride?.codexHome + left.homeRoute === right.homeRoute ) } diff --git a/src/main/codex/codex-pane-account-registry-types.ts b/src/main/codex/codex-pane-account-registry-types.ts index 446ca93e051..65666532a87 100644 --- a/src/main/codex/codex-pane-account-registry-types.ts +++ b/src/main/codex/codex-pane-account-registry-types.ts @@ -1,14 +1,4 @@ -import type { - CodexEnvironmentHomeOverride, - CodexShellStartupHomeOverride -} from './codex-real-home-path' - -export type CodexPaneHomeRoute = - | 'real-home' - | 'shared-home' - | 'account-home' - | 'custom-home' - | 'wsl-home' +export type CodexPaneHomeRoute = 'real-home' | 'shared-home' | 'account-home' | 'wsl-home' export type CodexPaneAccountRecord = { /** 'host' or 'wsl:' — the selection lane this pane launched from. */ @@ -17,10 +7,6 @@ export type CodexPaneAccountRecord = { accountId: string | null /** Absent only on records written before route provenance was introduced. */ homeRoute?: CodexPaneHomeRoute - /** Rechecked when CODEX_HOME came from process-global shell startup. */ - shellStartupHomeOverride?: CodexShellStartupHomeOverride - /** Rechecked after restart when CODEX_HOME came from the process environment. */ - environmentHomeOverride?: CodexEnvironmentHomeOverride } export type CodexPaneAccountRegistryFile = { diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index ebad8632676..55eac65e21c 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -8,10 +8,6 @@ import type { CodexPaneAccountRegistryFile, CodexPaneHomeRoute } from './codex-pane-account-registry-types' -import type { - CodexEnvironmentHomeOverride, - CodexShellStartupHomeOverride -} from './codex-real-home-path' export type { CodexPaneAccountRecord, @@ -129,45 +125,17 @@ function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile { } for (const [ptyId, record] of Object.entries(panes)) { if (isPaneAccountRecord(record)) { + const homeRoute = readPaneHomeRoute(record.homeRoute) empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId, - ...(isPaneHomeRoute(record.homeRoute) ? { homeRoute: record.homeRoute } : {}), - ...(isShellStartupHomeOverride(record.shellStartupHomeOverride) - ? { shellStartupHomeOverride: record.shellStartupHomeOverride } - : {}), - ...(isEnvironmentHomeOverride(record.environmentHomeOverride) - ? { environmentHomeOverride: record.environmentHomeOverride } - : {}) + ...(homeRoute ? { homeRoute } : {}) } } } return empty } -function isEnvironmentHomeOverride(value: unknown): value is CodexEnvironmentHomeOverride { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const context = value as Partial - return typeof context.codexHome === 'string' && context.codexHome.length > 0 -} - -function isShellStartupHomeOverride(value: unknown): value is CodexShellStartupHomeOverride { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const context = value as Partial - return ( - typeof context.home === 'string' && - context.home.length > 0 && - (context.shell === undefined || typeof context.shell === 'string') && - (context.configHome === undefined || typeof context.configHome === 'string') && - typeof context.codexHome === 'string' && - context.codexHome.length > 0 - ) -} - function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -180,14 +148,17 @@ function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { ) } -function isPaneHomeRoute(value: unknown): value is CodexPaneHomeRoute { - return ( - value === 'real-home' || +// Why: older builds wrote 'custom-home' for a shared-home pane with a pane-local CODEX_HOME. +function readPaneHomeRoute(value: unknown): CodexPaneHomeRoute | undefined { + if (value === 'custom-home') { + return 'shared-home' + } + return value === 'real-home' || value === 'shared-home' || value === 'account-home' || - value === 'custom-home' || value === 'wsl-home' - ) + ? value + : undefined } function writeRegistry(registry: CodexPaneAccountRegistryFile): boolean { @@ -242,7 +213,6 @@ export function getCodexPaneAccount(ptyId: string): CodexPaneAccountRecord | nul return readRegistry().panes[ptyId] ?? null } -/** `custom-home` stays conservative because it can mask a non-comparable shared-home route. */ export function isCodexPaneHomeRouteProvenAwayFromSharedHome( route: CodexPaneHomeRoute | undefined ): boolean { @@ -289,9 +259,7 @@ export function hasRecordedLegacySharedCodexPane(): boolean { return Object.values(readRegistry().panes).some( (record) => record.selectionKey === 'host' && - (record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home') + (record.homeRoute === undefined || record.homeRoute === 'shared-home') ) } @@ -337,7 +305,6 @@ export function hasRecordedManagedHostCodexPane(): boolean { record.selectionKey === 'host' && (record.homeRoute === undefined || record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' || (record.homeRoute === 'account-home' && record.accountId !== null)) ) } diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index dcf8ab00512..10d025876ca 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -75,34 +75,6 @@ describe('resolveCodexPaneLaunchAccount', () => { }) }) - it('records a non-comparable route for a pane-local custom home', () => { - expect( - resolveCodexPaneLaunchAccount({ - pinnedByResume: false, - launchCodexHomePath: '/data/codex-runtime-home/home', - recordComparableHomeRoute: false, - systemCodexHomePath: SYSTEM_HOME, - settings: settings({ host: null }), - target: { runtime: 'host' } - }) - ).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' }) - }) - - it('keeps an account-owned route comparable when a pane override is ignored', () => { - const accounts = [managedAccount({ id: 'account-a' })] - - expect( - resolveCodexPaneLaunchAccount({ - pinnedByResume: false, - launchCodexHomePath: '/data/codex-accounts/account-a/home', - recordComparableHomeRoute: false, - systemCodexHomePath: SYSTEM_HOME, - settings: settings({ host: 'account-a', accounts }), - target: { runtime: 'host' } - }) - ).toEqual({ selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }) - }) - it('maps a resume redirected to the real system home to the system-default account', () => { expect( resolveCodexPaneLaunchAccount({ diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 2ceac661e4e..bef05d044a1 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -8,10 +8,6 @@ import { type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { CodexPaneAccountRecord, CodexPaneHomeRoute } from './codex-pane-account-registry' -import type { - CodexEnvironmentHomeOverride, - CodexShellStartupHomeOverride -} from './codex-real-home-path' type CodexPaneLaunchAccountSettings = Pick< GlobalSettings, @@ -33,46 +29,21 @@ type CodexPaneLaunchAccountSettings = Pick< export function resolveCodexPaneLaunchAccount(args: { pinnedByResume: boolean launchCodexHomePath: string | null - recordComparableHomeRoute?: boolean - shellStartupHomeOverride?: CodexShellStartupHomeOverride - environmentHomeOverride?: CodexEnvironmentHomeOverride systemCodexHomePath: string settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneAccountRecord | null { - const selectionKey = getCodexSelectionLaneKey(args.target) - const resolvedHomeRoute = resolveCodexPaneHomeRoute(args) - const homeRoute = - args.recordComparableHomeRoute === false && resolvedHomeRoute === 'shared-home' - ? 'custom-home' - : resolvedHomeRoute - if (!args.pinnedByResume) { - return { - selectionKey, - accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target), - ...(homeRoute ? { homeRoute } : {}), - ...(args.shellStartupHomeOverride - ? { shellStartupHomeOverride: args.shellStartupHomeOverride } - : {}), - ...(args.environmentHomeOverride - ? { environmentHomeOverride: args.environmentHomeOverride } - : {}) - } + const accountId = args.pinnedByResume + ? resolveCodexHomeOwnerAccountId(args) + : getSelectedCodexAccountIdForTarget(args.settings, args.target) + if (accountId === undefined) { + return null + } + return { + selectionKey: getCodexSelectionLaneKey(args.target), + accountId, + homeRoute: resolveCodexPaneHomeRoute(args) } - const accountId = resolveCodexHomeOwnerAccountId(args) - return accountId === undefined - ? null - : { - selectionKey, - accountId, - ...(homeRoute ? { homeRoute } : {}), - ...(args.shellStartupHomeOverride - ? { shellStartupHomeOverride: args.shellStartupHomeOverride } - : {}), - ...(args.environmentHomeOverride - ? { environmentHomeOverride: args.environmentHomeOverride } - : {}) - } } function resolveCodexPaneHomeRoute(args: { diff --git a/src/main/codex/codex-real-home-path.test.ts b/src/main/codex/codex-real-home-path.test.ts index 25d4f2b5bba..7def880f767 100644 --- a/src/main/codex/codex-real-home-path.test.ts +++ b/src/main/codex/codex-real-home-path.test.ts @@ -3,10 +3,8 @@ import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { - getCustomCodexHomeOverrideForLaunch, hasCustomCodexHomeOverride, - hasCustomCodexHomeOverrideForLaunch, - shellStartupCodexHomeOverrideMatches + hasCustomCodexHomeOverrideForLaunch } from './codex-real-home-path' import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' @@ -46,13 +44,10 @@ describe('hasCustomCodexHomeOverride', () => { ).toBe(true) }) - it('captures explicit environment provenance for restart comparison', () => { - const codexHome = join(process.cwd(), 'custom-codex-home') - - expect(getCustomCodexHomeOverrideForLaunch({ CODEX_HOME: codexHome })).toEqual({ - source: 'environment', - context: { codexHome } - }) + it('detects an explicit launch env CODEX_HOME', () => { + expect( + hasCustomCodexHomeOverrideForLaunch({ CODEX_HOME: join(process.cwd(), 'custom-codex-home') }) + ).toBe(true) }) it.skipIf(process.platform === 'win32')( @@ -62,26 +57,7 @@ describe('hasCustomCodexHomeOverride', () => { temporaryHomes.push(paneHome) writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n') - // Why cleared: the context records XDG_CONFIG_HOME, so a developer machine - // that sets one would otherwise change the recorded shape. - delete process.env.XDG_CONFIG_HOME expect(hasCustomCodexHomeOverrideForLaunch({ HOME: paneHome, SHELL: '/bin/zsh' })).toBe(true) - const override = getCustomCodexHomeOverrideForLaunch({ - HOME: paneHome, - SHELL: '/bin/zsh' - }) - expect(override).toEqual({ - source: 'shell-startup', - context: { - home: paneHome, - shell: '/bin/zsh', - codexHome: join(paneHome, 'custom-codex-home') - } - }) - expect( - override?.source === 'shell-startup' && - shellStartupCodexHomeOverrideMatches(override.context) - ).toBe(true) } ) @@ -99,12 +75,6 @@ describe('hasCustomCodexHomeOverride', () => { join(configHome, 'fish', 'config.fish'), 'set -gx CODEX_HOME "$HOME/custom-codex-home"\n' ) - // The decoy fish reads only if XDG_CONFIG_HOME is ignored. - mkdirSync(join(paneHome, '.config', 'fish'), { recursive: true }) - writeFileSync( - join(paneHome, '.config', 'fish', 'config.fish'), - 'set -gx CODEX_HOME /wrong-default-config-home\n' - ) delete process.env.XDG_CONFIG_HOME const launchEnv = { @@ -112,22 +82,7 @@ describe('hasCustomCodexHomeOverride', () => { SHELL: '/opt/homebrew/bin/fish', XDG_CONFIG_HOME: configHome } - const override = getCustomCodexHomeOverrideForLaunch(launchEnv) - - expect(override).toEqual({ - source: 'shell-startup', - context: { - home: paneHome, - shell: '/opt/homebrew/bin/fish', - configHome, - codexHome: join(paneHome, 'custom-codex-home') - } - }) - // And the recorded configHome is what a later re-check resolves against. - expect( - override?.source === 'shell-startup' && - shellStartupCodexHomeOverrideMatches(override.context) - ).toBe(true) + expect(hasCustomCodexHomeOverrideForLaunch(launchEnv)).toBe(true) } ) }) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index bdc4ca3425c..fcb29644341 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -3,22 +3,6 @@ import { getSystemCodexHomePath } from './codex-home-paths' import { readBashStartupEnvVar, readShellStartupEnvVar } from '../pty/shell-startup-env' import { readPowerShellProfileEnvValues } from '../pty/powershell-profile-env' -export type CodexShellStartupHomeOverride = { - home: string - shell?: string - /** Why recorded: fish reads config under it, so re-reads must use the same root. */ - configHome?: string - codexHome: string -} - -export type CodexEnvironmentHomeOverride = { - codexHome: string -} - -export type CustomCodexHomeOverrideForLaunch = - | { source: 'environment'; context: CodexEnvironmentHomeOverride } - | { source: 'shell-startup'; context: CodexShellStartupHomeOverride } - /** True when the user points Codex outside its standard native home. */ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean { const codexHome = env.CODEX_HOME?.trim() @@ -36,100 +20,39 @@ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env) ) } +/** True when the launch env, or a shell startup file it reads, points Codex at a custom home. */ export function hasCustomCodexHomeOverrideForLaunch(launchEnv?: NodeJS.ProcessEnv): boolean { - return getCustomCodexHomeOverrideForLaunch(launchEnv) !== null -} - -export function getCustomCodexHomeOverrideForLaunch( - launchEnv?: NodeJS.ProcessEnv -): CustomCodexHomeOverrideForLaunch | null { - const effectiveEnv = { - CODEX_HOME: getLaunchEnvValue(launchEnv, 'CODEX_HOME'), - ORCA_CODEX_HOME: getLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') - } - if (hasCustomCodexHomeOverride(effectiveEnv)) { - return { - source: 'environment', - context: { codexHome: effectiveEnv.CODEX_HOME!.trim() } - } + if ( + hasCustomCodexHomeOverride({ + CODEX_HOME: getLaunchEnvValue(launchEnv, 'CODEX_HOME'), + ORCA_CODEX_HOME: getLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') + }) + ) { + return true } // Why USERPROFILE: Windows has no HOME, and PowerShell profiles hang off it. const home = getLaunchEnvValue(launchEnv, process.platform === 'win32' ? 'USERPROFILE' : 'HOME') - const shell = getLaunchEnvValue(launchEnv, 'SHELL') - const configHome = getLaunchEnvValue(launchEnv, 'XDG_CONFIG_HOME') - const [shellCodexHome] = readCustomShellStartupCodexHomes(home, shell, configHome) - if (!home || !shellCodexHome) { - return null - } - return { - source: 'shell-startup', - context: { - home, - ...(shell ? { shell } : {}), - ...(configHome ? { configHome } : {}), - codexHome: shellCodexHome - } - } -} - -export function environmentCodexHomeOverrideContextsEqual( - left: CodexEnvironmentHomeOverride, - right: CodexEnvironmentHomeOverride -): boolean { - return normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) -} - -export function shellStartupCodexHomeOverrideMatches( - context: CodexShellStartupHomeOverride, - currentContext: CodexShellStartupHomeOverride = context -): boolean { - if (!shellStartupCodexHomeOverrideContextsEqual(context, currentContext)) { + if (!home) { return false } - return readCustomShellStartupCodexHomes( - currentContext.home, - currentContext.shell, - currentContext.configHome - ).some( - (codexHome) => - normalizePathForComparison(codexHome) === normalizePathForComparison(context.codexHome) - ) -} - -export function shellStartupCodexHomeOverrideContextsEqual( - left: CodexShellStartupHomeOverride, - right: CodexShellStartupHomeOverride -): boolean { - return ( - normalizePathForComparison(left.home) === normalizePathForComparison(right.home) && - left.shell === right.shell && - left.configHome === right.configHome && - normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) - ) -} - -/** - * Custom CODEX_HOMEs the pane's shell startup may set. A Windows pane may run - * either PowerShell edition or Git Bash, so any of their startup files counts. - */ -function readCustomShellStartupCodexHomes( - home: string | undefined, - shell: string | undefined, - configHome: string | undefined -): string[] { - if (!home) { - return [] - } - const candidates = + // Why: a Windows pane may run either PowerShell edition or Git Bash, so any of their + // startup files counts. + const shellCodexHomes = process.platform === 'win32' ? [ ...readPowerShellProfileEnvValues('CODEX_HOME', home), readBashStartupEnvVar('CODEX_HOME', home) ] - : [readShellStartupEnvVar('CODEX_HOME', home, shell, configHome)] - return candidates.filter( - (codexHome): codexHome is string => - codexHome !== undefined && hasCustomCodexHomeOverride({ CODEX_HOME: codexHome }) + : [ + readShellStartupEnvVar( + 'CODEX_HOME', + home, + getLaunchEnvValue(launchEnv, 'SHELL'), + getLaunchEnvValue(launchEnv, 'XDG_CONFIG_HOME') + ) + ] + return shellCodexHomes.some( + (codexHome) => codexHome !== undefined && hasCustomCodexHomeOverride({ CODEX_HOME: codexHome }) ) } 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 a7baf6a14b5..454c3ccd4a0 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-shared-server-pane.test.ts b/src/main/codex/codex-shared-server-pane.test.ts index c378a6763b3..5998226e745 100644 --- a/src/main/codex/codex-shared-server-pane.test.ts +++ b/src/main/codex/codex-shared-server-pane.test.ts @@ -88,25 +88,6 @@ describe('findPaneCodex', () => { describe('resolveCodexPaneHome', () => { it.each([ [{ selectionKey: 'host', accountId: null, homeRoute: 'real-home' }, '/home/me/.codex'], - [ - { - selectionKey: 'host', - accountId: null, - homeRoute: 'real-home', - environmentHomeOverride: { codexHome: '/custom/codex' } - }, - '/custom/codex' - ], - [ - { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home', - shellStartupHomeOverride: { home: '/home/me', codexHome: '/rc/codex' } - }, - '/rc/codex' - ], - [{ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' }, null], // Orca's mirror: a fallback lane or a pre-upgrade pane's retired home. [{ selectionKey: 'host', accountId: null, homeRoute: 'shared-home' }, null], [{ selectionKey: 'host', accountId: 'acct', homeRoute: 'account-home' }, null], diff --git a/src/main/codex/codex-shared-server-pane.ts b/src/main/codex/codex-shared-server-pane.ts index 1f9bc68f681..ca246484131 100644 --- a/src/main/codex/codex-shared-server-pane.ts +++ b/src/main/codex/codex-shared-server-pane.ts @@ -71,13 +71,10 @@ export function resolveCodexPaneHome(ptyId: string): string | null { if (record?.selectionKey !== 'host') { return null } - const customHome = - record.environmentHomeOverride?.codexHome ?? record.shellStartupHomeOverride?.codexHome switch (record.homeRoute) { + // Why: a custom CODEX_HOME routes a pane to Orca's mirror, so real-home is always ~/.codex. case 'real-home': - return customHome ?? getSystemCodexHomePath() - case 'custom-home': - return customHome ?? null + return getSystemCodexHomePath() // Why: an unnamed home (managed account, WSL, pre-route record) skips the // warning rather than probing the wrong server. Orca's mirror (shared-home) // gets none either, as macOS and Linux already did: it is a fallback lane diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 7a87fee713c..ac21c3d5604 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -53,7 +53,6 @@ describe('codex pane account registry', () => { ['account-home', true], ['wsl-home', true], ['shared-home', false], - ['custom-home', false], [undefined, false] ] as const)('classifies whether %s proves a pane avoided the shared home', (route, expected) => { expect(isCodexPaneHomeRouteProvenAwayFromSharedHome(route)).toBe(expected) @@ -63,12 +62,7 @@ describe('codex pane account registry', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a', - homeRoute: 'account-home', - shellStartupHomeOverride: { - home: '/pane-home', - shell: '/bin/zsh', - codexHome: '/pane-home/custom-codex-home' - } + homeRoute: 'account-home' }) _internals.resetCache() @@ -76,12 +70,7 @@ describe('codex pane account registry', () => { expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a', - homeRoute: 'account-home', - shellStartupHomeOverride: { - home: '/pane-home', - shell: '/bin/zsh', - codexHome: '/pane-home/custom-codex-home' - } + homeRoute: 'account-home' }) }) @@ -99,6 +88,23 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('reads a custom-home record from an older build as the shared home it was', () => { + writeFileSync( + join(userDataPath, 'codex-pane-accounts.json'), + JSON.stringify({ + version: 2, + panes: { 'pty-1': { selectionKey: 'host', accountId: null, homeRoute: 'custom-home' } } + }) + ) + _internals.resetCache() + + expect(getCodexPaneAccount('pty-1')).toEqual({ + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + }) + it('runs legacy reconciliation only for host panes that may use the shared home', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', @@ -118,17 +124,6 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(false) - recordCodexPaneAccount('pty-custom', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - - expect(hasRecordedLegacySharedCodexPane()).toBe(true) - - forgetCodexPaneAccount('pty-custom') - expect(hasRecordedLegacySharedCodexPane()).toBe(false) - recordCodexPaneAccount('pty-shared', { selectionKey: 'host', accountId: null, @@ -359,8 +354,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-1', launchAccountId: 'account-a', - activeAccountId: 'account-b', - reason: 'account-change' + activeAccountId: 'account-b' } ]) }) @@ -374,8 +368,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-1', launchAccountId: 'account-a', - activeAccountId: null, - reason: 'account-change' + activeAccountId: null } ]) }) @@ -388,7 +381,7 @@ describe('listStaleCodexPanes', () => { ).toEqual([]) }) - it('reports a system-default pane after its home route changes', () => { + it('leaves a pane on an older Codex home alone while its account is unchanged', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null, @@ -396,86 +389,7 @@ describe('listStaleCodexPanes', () => { }) expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: null, - reason: 'home-route-change' - } - ]) - }) - - it('keeps account-switch copy when the account and home route both change', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'real-home' - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection('account-a'), - activeHostHomeRoute: 'account-home' - }) - ).toEqual([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: 'account-a', - reason: 'account-change' - } - ]) - }) - - it('does not guess a route for panes recorded before route provenance', () => { - recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([]) - }) - - it('does not compare a pane-local custom home with the selected host route', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([]) - }) - - it('leaves a retained pane alone while its process CODEX_HOME is unchanged', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'shared-home', - environmentHomeOverride: { codexHome: '/custom/codex-home' } - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'shared-home' - }) + listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection(null) }) ).toEqual([]) }) @@ -502,8 +416,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-2', launchAccountId: 'account-a', - activeAccountId: 'account-b', - reason: 'account-change' + activeAccountId: 'account-b' } ]) }) @@ -523,8 +436,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-2', launchAccountId: 'account-c', - activeAccountId: 'account-d', - reason: 'account-change' + activeAccountId: 'account-d' } ]) }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index c7e789b3b9b..99a9cdf422a 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -2,25 +2,23 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' import { forgetCodexPaneAccount, - listRecordedCodexPaneAccounts, - type CodexPaneHomeRoute + listRecordedCodexPaneAccounts } from './codex-pane-account-registry' export type StaleCodexPane = { ptyId: string launchAccountId: string | null activeAccountId: string | null - reason: 'account-change' | 'home-route-change' } /** * Reports which of the given PTYs still launch Codex as a previously selected * account, so the restart prompt survives an app restart the shells outlive. + * Why only the account: a pane on an older Codex home keeps working, refreshed from ~/.codex. */ export function listStaleCodexPanes(args: { ptyIds: readonly string[] settings: GlobalSettings - activeHostHomeRoute?: CodexPaneHomeRoute }): StaleCodexPane[] { const stalePanes: StaleCodexPane[] = [] const records = listRecordedCodexPaneAccounts(args.ptyIds) @@ -33,20 +31,8 @@ export function listStaleCodexPanes(args: { args.settings, parseSelectionLaneKey(record.selectionKey) ) - const homeRouteChanged = - record.selectionKey === 'host' && - record.homeRoute !== undefined && - record.homeRoute !== 'custom-home' && - args.activeHostHomeRoute !== undefined && - record.homeRoute !== args.activeHostHomeRoute - const accountChanged = record.accountId !== activeAccountId - if (accountChanged || homeRouteChanged) { - stalePanes.push({ - ptyId, - launchAccountId: record.accountId, - activeAccountId, - reason: accountChanged ? 'account-change' : 'home-route-change' - }) + if (record.accountId !== activeAccountId) { + stalePanes.push({ ptyId, launchAccountId: record.accountId, activeAccountId }) } } return stalePanes diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 62ecaf8f2ea..1e548dd0909 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -1,5 +1,6 @@ // A Codex Stop that names no turn. The fake keeps Codex 0.157's turn bookkeeping: it answers -// `turn/start` before it opens the turn, and refuses an interrupt until then. +// `turn/start` before it opens the turn, and refuses an interrupt with no turn active until the +// thread runs. import { describe, expect, it, vi } from 'vitest' import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' @@ -29,7 +30,7 @@ describe('a Codex Stop that names no turn', () => { const rig = await codexTurnLifecycleRig() await openedTurn(rig) - await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true }) + await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 775cac5bca4..1eee3bc9a0a 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -44,6 +44,9 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void + /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may + * still open it, though no wait is spent on it again. */ + answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it * completed, which echoes its pending input first, so one it never echoed waits for recovery. An @@ -68,6 +71,19 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' + const answeredTurn = ( + threadId: string, + openTurnIds: ReadonlySet, + admits: (key: string) => boolean + ): string | null => { + const answered = [...armed.values()].flatMap(({ turn }) => { + const key = turn?.threadId === threadId ? turnKey(threadId, turn.turnId) : null + return turn && key && !openTurnIds.has(turn.turnId) && !endedTurns.has(key) && admits(key) + ? [turn.turnId] + : [] + }) + return answered.at(-1) ?? null + } return { arm(clientMessageId, requestedAt) { const existing = armed.get(clientMessageId) @@ -97,17 +113,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } return end }, - answeredUnopenedTurn: (threadId, openTurnIds) => { - const answered = [...armed.values()].flatMap(({ turn }) => - turn?.threadId === threadId && - !openTurnIds.has(turn.turnId) && - !endedTurns.has(turnKey(threadId, turn.turnId)) && - !unopenedTurns.has(turnKey(threadId, turn.turnId)) - ? [turn.turnId] - : [] - ) - return answered.at(-1) ?? null - }, + answeredUnopenedTurn: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => !unopenedTurns.has(key)), + answeredTurnLeftUnopened: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => unopenedTurns.has(key)), leftUnopened: (threadId, turnId) => { unopenedTurns.add(turnKey(threadId, turnId)) for (const oldest of unopenedTurns) { diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index ef1f8cc426f..a0b2dc4146e 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -509,7 +509,7 @@ describe('Codex live prompt ownership', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(completionThreads(events)).toEqual([]) }) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index effab9662bf..fdef6e750f4 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -12,7 +12,7 @@ import { } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' -import { codexRunningOrOpeningTurn } from './codex-structured-turn-open-wait' +import { codexStopTarget, interruptOpeningCodexTurn } from './codex-structured-turn-open-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -22,10 +22,25 @@ function providerTurnId(session: CodexSession, turnId: string): string | undefin return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId } +/** A send whose answer this child lost: Codex may still open a turn for it that no Stop can name. + * A pending one Codex answered is not: its turn is the one the Stop interrupts. */ +function sendInDoubt(dispatchStatus: CancelInput['dispatchStatus']): boolean { + return dispatchStatus?.state === 'unknown' && !dispatchStatus.recovered +} + +/** The Stop's own answer, naming the journal turn it stopped when it took. */ +function stoppedTurn( + outcome: AgentSessionCancelOutcome, + turnId: string +): AgentSessionCancelOutcome { + return outcome.cancelled ? { ...outcome, turnId } : outcome +} + /** * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported * started and not yet ended, which the journal can trail by a publish, or else the one Codex - * answered a send into, once it opens. + * answered a send into, at once (`interruptOpeningCodexTurn`). A turn that may still open but + * cannot be reached, or a send whose answer was lost, answers refused, and the host ends the child. */ async function cancelCodexConversation( input: Parameters[0], @@ -34,20 +49,32 @@ async function cancelCodexConversation( const { request, sessions, requestTimeoutMs } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. - const turnId = - liveTurnId === null - ? await codexRunningOrOpeningTurn(session) - : providerTurnId(session, liveTurnId) - // The wait for the turn to open can outlive the session it began on. - if ( - !turnId || - sessions.get(request.sessionId) !== session || - session.ended || - session.fence !== request.fence - ) { + const target = liveTurnId === null ? codexStopTarget(session) : { turnId: liveTurnId } + // Read again after any wait for the turn to open, which can outlive the session it began on. + const current = (): boolean => + sessions.get(request.sessionId) === session && !session.ended && session.fence === request.fence + if (!current()) { return { cancelled: false } } - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (target === null) { + return sendInDoubt(request.dispatchStatus) + ? { cancelled: false, refusal: { turnMayOpen: true } } + : { cancelled: false } + } + const journalTurnId = 'opening' in target ? target.opening : target.turnId + const turnId = liveTurnId === null ? journalTurnId : providerTurnId(session, liveTurnId) + if (!turnId) { + return { cancelled: false } + } + const interrupt = () => + interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (!('opening' in target)) { + return stoppedTurn(await interrupt(), journalTurnId) + } + return stoppedTurn( + await interruptOpeningCodexTurn(session, turnId, interrupt, current), + journalTurnId + ) } export async function cancelCodexStructuredTurn(input: { @@ -67,7 +94,10 @@ export async function cancelCodexStructuredTurn(input: { return { cancelled: false } } if (!prompt) { - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + return stoppedTurn( + await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), + requestedTurnId + ) } if (session.fence !== request.fence) { return { cancelled: false } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a..ae965795f7d 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 e3211f320e8..2e553cb9e0b 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 9276d670b4c..0e674bf10de 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 13a687a72cd..6f67b68f8e6 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-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index cd30bfa2e6d..939d5faf0cd 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -143,7 +143,7 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(codex.connections[0].calls.at(-1)).toEqual({ method: 'turn/interrupt', params: { threadId: THREAD_ID, turnId: 'turn-1' } @@ -288,7 +288,7 @@ describe('a Codex Stop is the interrupt alone', () => { params: expect.objectContaining({ turn: { id: 'turn-1', status: 'interrupted' } }) }) ) - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('reads and kills no processes, from the send through the Stop', async () => { @@ -299,7 +299,7 @@ describe('a Codex Stop is the interrupt alone', () => { for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 9d80a0fa982..4906b45a529 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 aac9e2946cf..ad4935e5d4b 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 d99ecc0305e..4df25a9477c 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 a47cb5a78fe..6ddc11ac1f6 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 f9f2b78b7f9..96359286b67 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 fede869a83c..efd32b666cd 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 0f8b530d624..19a3980cb20 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/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index c86a39fb59e..d669c088d08 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -1,7 +1,8 @@ // A Codex Stop that names no turn, sent after Codex answered a cold send and before it opened that -// turn, waits for the turn to open, or provably not to, and never the send itself. The fake keeps -// Codex 0.157's turn bookkeeping: it answers before it opens the turn, and refuses an interrupt -// until then. +// turn, interrupts that turn at once. Refused as finding no turn while the turn has yet to run, it +// waits for the turn to open, or provably not to, and interrupts once more; it never waits for the +// send itself. The fake keeps Codex 0.157's turn bookkeeping: it answers before it opens the turn, +// and refuses an interrupt with no turn active until the thread runs. import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -15,6 +16,7 @@ type Rig = Awaited> const ADMITTED = { state: 'admitted' } const REFUSED = { cancelled: false } +const MAY_OPEN = { cancelled: false, refusal: { turnMayOpen: true } } const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) @@ -26,12 +28,23 @@ async function answeredColdSend(rig: Rig): Promise { expect(await settledWithin(sending)).toEqual(ADMITTED) } -/** A Stop sent in the window, which Codex would refuse if it reached Codex now. */ +const interruptedTurns = (rig: Rig) => rig.interrupts().map((call) => call.params?.turnId) + +/** A Stop under fake timers, read without awaiting it. */ +function pressed(rig: Rig): () => unknown { + let outcome: unknown = 'held' + void stop(rig).then((value) => { + outcome = value + }) + return () => outcome +} + +/** A Stop sent in the window before Codex's thread runs the turn: Codex refused its interrupt. */ async function waitingStop(rig: Rig) { await answeredColdSend(rig) const stopping = stop(rig) expect(await settledWithin(stopping)).toBe('held') - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) // Wrapped: an async function returning the promise itself would wait for it. return { stopping } } @@ -51,15 +64,26 @@ describe('a Codex send answered before its turn opens', () => { }) describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { - it('waits for the turn to open, then stops it', async () => { + it("is taken at once once Codex's thread runs the turn", async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + rig.turns.run() + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) + }) + + it('refused before the thread runs, waits for the turn to open and interrupts once more', async () => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) - expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) - expect(rig.turns.turnId).toBeNull() + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) + // Codex answers the interrupt, then ends the turn. + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) }) it('does not wait when Codex opened the turn before its answer was read', async () => { @@ -71,7 +95,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi release() await sending - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('does not wait for a send Codex steered into the running turn', async () => { @@ -80,7 +104,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.start() expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) @@ -91,19 +115,41 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.end('interrupted') expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) + // Codex ended the turn as the interrupt reached it: it refuses the turn as its last ended one. + it('stops nothing, and waits for nothing, when Codex already ended the turn', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + const interrupt = rig.codex.routes['turn/interrupt']! + rig.codex.routes['turn/interrupt'] = (params) => { + rig.turns.end('completed') + return interrupt(params) + } + + expect(await settledWithin(stop(rig))).toEqual(REFUSED) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + }) + + // The turn neither opened nor ended, and its send is still owed: no press calls that nothing. it.each(['idle', 'systemError'])( - 'stops nothing when Codex reports the thread %s', + 'answers refused, on the first press and on a second alike, when Codex reports the thread %s', async (type) => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) - expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(await settledWithin(stopping)).toEqual(MAY_OPEN) + // The second press's interrupt is refused too; it waits its own bound, then agrees. + vi.useFakeTimers() + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + await vi.advanceTimersByTimeAsync(1) + expect(second()).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) } ) @@ -115,7 +161,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(await settledWithin(stopping)).toBe('held') rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('stops nothing when the child exits', async () => { @@ -125,23 +171,68 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) - it('stops nothing once its bound runs out', async () => { + // The turn may still open and run: the host ends the child (`performCancel`). + it('answers refused once its bound runs out', async () => { const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) vi.useFakeTimers() - let outcome: unknown = 'held' - void stop(rig).then((value) => { - outcome = value - }) + const first = pressed(rig) await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) - expect(outcome).toBe('held') + expect(first()).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual(REFUSED) + expect(first()).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + }) + + // A press after an earlier wait gave up waits again, so a turn that opens then is still stopped. + it('waits again on a second press, and stops the turn if it opens then', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + vi.useFakeTimers() + const first = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) + expect(first()).toEqual(MAY_OPEN) + + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + rig.turns.start() + await vi.advanceTimersByTimeAsync(0) + + expect(second()).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1', 'turn-1']) + }) +}) + +describe('a no-turn Stop with no turn Codex answered', () => { + it('answers refused when the answer to the latest send was lost: Codex may still open its turn', async () => { + const rig = await codexTurnLifecycleRig() + + const outcome = await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: false } + }) + + expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) expect(rig.interrupts()).toEqual([]) + }) + + it('stops nothing when nothing was sent, or the lost answer outlived its child', async () => { + const rig = await codexTurnLifecycleRig() + + expect(await stop(rig)).toEqual(REFUSED) + expect( + await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: true } + }) + ).toEqual(REFUSED) }) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 9f30b292ff0..9dd071656f3 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -1,6 +1,7 @@ // A Stop's or a send's wait for the turn Codex answered a send into to open, or provably not // to: it ended, the thread stopped running, or the child is gone. Held in memory only. +import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { codexThreadStoppedRunning, @@ -61,18 +62,88 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } } -/** - * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. Null when none is running and that one ends, the - * thread stops running or the child exits first, or the wait runs out; each such turn is waited - * for once. - */ -export async function codexRunningOrOpeningTurn(session: { +/** What a send finds to steer, or a Stop to interrupt: a turn running, or one Codex answered a send + * into that has not opened and has not ended, so may still open while its send is owed; null when + * neither. */ +export type CodexStopTarget = { turnId: string } | { opening: string } | null + +type CodexOpeningTurnSession = { threadId: string activeTurnIds?: ReadonlySet - dispatchEchoes: Pick + dispatchEchoes: Pick< + CodexDispatchEchoes, + 'answeredUnopenedTurn' | 'leftUnopened' | 'answeredTurnLeftUnopened' + > turnOpenWaits: Pick -}): Promise { +} + +/** The latest turn Codex reported started and not ended, or else the one it answered a send into + * that has neither opened nor ended, whether or not a wait already gave up on it. */ +export function codexStopTarget(session: CodexOpeningTurnSession): CodexStopTarget { + const running = [...(session.activeTurnIds ?? [])].at(-1) + if (running) { + return { turnId: running } + } + const open = session.activeTurnIds ?? new Set() + const opening = + session.dispatchEchoes.answeredUnopenedTurn(session.threadId, open) ?? + session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open) + return opening ? { opening } : null +} + +/** Codex's -32600 for an interrupt that finds no turn: before the turn has started running, or + * once it has ended. */ +const CODEX_NO_ACTIVE_TURN_TO_INTERRUPT = 'no active turn to interrupt' + +/** + * A Stop of a turn Codex answered a send into that has not opened. Codex takes an interrupt as + * soon as the turn starts, which can be before its `turn/started` is read, so it goes out at once. + * Refused as finding no turn while that turn has yet to start, the Stop waits for it to open, + * bounded, and sends it once more. A turn that ended meanwhile was nothing to stop. One that never + * opens fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. + */ +export async function interruptOpeningCodexTurn( + session: CodexOpeningTurnSession, + turnId: string, + interrupt: () => Promise, + /** Whether the session the Stop began on still runs it, read after the wait. */ + stillCurrent: () => boolean +): Promise { + const open = (): ReadonlySet => session.activeTurnIds ?? new Set() + const first = await interrupt() + if (first.cancelled || first.refusal?.detail?.text !== CODEX_NO_ACTIVE_TURN_TO_INTERRUPT) { + return first + } + const opened = (): boolean => open().has(turnId) + const stillOpening = (): boolean => { + const target = codexStopTarget(session) + return target !== null && 'opening' in target && target.opening === turnId + } + if (!opened() && stillOpening()) { + await session.turnOpenWaits.wait(turnId, CODEX_TURN_OPEN_WAIT_MS) + if (!stillCurrent()) { + return { cancelled: false } + } + } + if (opened()) { + return interrupt() + } + if (!stillOpening()) { + // It ended: the Stop found no turn running. + return { cancelled: false } + } + return { cancelled: false, refusal: { turnMayOpen: true } } +} + +/** + * The turn a send steers into: the running one, or else the one Codex answered a send into, once + * it opens. Before 0.148 Codex refuses a steer until it opens the turn, so the send waits for that, + * once per turn: a turn a wait left unopened is not waited for again. Null when no turn is running + * by then. + */ +export async function codexRunningOrOpeningTurn( + session: CodexOpeningTurnSession +): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { return running @@ -84,7 +155,6 @@ export async function codexRunningOrOpeningTurn(session: { if (!answered) { return null } - // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { return answered diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index c1b982709b3..15f95aa2927 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -223,12 +223,16 @@ describe('a Codex send made after Codex answered an earlier one, before it opene expect(await rig.sending).toEqual({ state: 'admitted' }) expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/steer']) expect(await rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).toEqual({ - cancelled: true + cancelled: true, + turnId: 'turn-1' }) - expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ - 'client-1', - 'client-2' - ]) + // Codex answers the interrupt, then ends the turn, which settles both. + await vi.waitFor(() => + expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ + 'client-1', + 'client-2' + ]) + ) }) it('starts its own turn when that turn ends without opening', async () => { diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index a958faca1ea..e0747f38b0b 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -2,9 +2,13 @@ // it, for tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it // answers, a send while a turn is open is steered into it under the same id with no // second `turn/started`, `turn/steer` refuses with -32600 unless its `expectedTurnId` is -// the running turn, and `turn_interrupt_inner` refuses with -32600 until the -// turn has started. The answer can be held, so a test can deliver it after the -// turn's own frames, as the wire allows. `legacyStartAnswers` is a Codex before 0.148, +// the running turn, and `turn_interrupt_inner`, with no turn active, refuses with -32600 "no +// active turn to interrupt" until the thread runs (`run`, or `start`) and once the turn has ended. +// An interrupt Codex takes is answered before its turn ends: on `TurnAborted`, +// `bespoke_event_handling.rs` answers pending interrupts, then sends `turn/completed` +// (interrupted) on the same channel; here that end comes on a later read. The `turn/start` +// answer can be held, so a test can deliver it after the turn's own frames, as the wire +// allows. `legacyStartAnswers` is a Codex before 0.148, // whose `turn/start` answers a steered send with its submission id, a turn that never opens, // and whose `turn/steer` finds no turn to steer until the picked one has started. @@ -22,7 +26,13 @@ export type CodexTurnLifecycleFake = { holdNextAnswer: () => () => void /** Codex emits `turn/started` for the turn it picked last. */ start: () => void - /** Codex ends the picked or running turn on its own. */ + /** The thread runs the picked turn, whose `turn/started` has yet to be read: an interrupt of it + * is taken, and that `turn/started` comes ahead of the answer. */ + run: () => void + /** Codex takes an interrupt of the running turn: it drops it as its active turn before it + * answers, and that turn's `turn/completed` comes later (`end`). */ + takeInterrupt: () => string + /** Codex ends the picked or running turn on its own, or the one whose interrupt it took. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ failUnopened: (message: string) => void @@ -51,14 +61,41 @@ export function codexTurnLifecycleFake( let active: string | null = null let lastTurn: string | null = null let held: Promise | null = null + let pickedRuns = false + // The turn whose interrupt Codex took and whose `turn/completed` is still to come. + let aborting: string | null = null const finish = (turnId: string, status: string, errorMessage?: string): void => { - picked = null - active = null + // A taken interrupt's turn is no longer active: a turn picked since stays picked. + if (aborting === turnId) { + aborting = null + } else { + picked = null + active = null + pickedRuns = false + } notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } }) } + + const takeInterrupt = (): string => { + if (!active) { + throw new Error('no running turn to interrupt') + } + aborting = active + active = null + picked = null + return aborting + } + const open = (): void => { + if (!picked) { + throw new Error('no picked turn to start') + } + active = picked + pickedRuns = false + notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + } return { routes: { 'turn/start': () => { @@ -88,6 +125,9 @@ export function codexTurnLifecycleFake( }, 'turn/interrupt': (params) => { const turnId = params?.turnId + if (!active && pickedRuns && picked === turnId) { + open() + } if (!active) { throw refusal('turn/interrupt', 'no active turn to interrupt') } @@ -97,8 +137,13 @@ export function codexTurnLifecycleFake( `expected active turn id ${String(turnId)} but found ${active}` ) } - // Codex answers the interrupt once the turn has aborted. - finish(active, 'interrupted') + const aborted = takeInterrupt() + // Answered, then the turn's end on the next read; a turn that ends meanwhile is not ended twice. + setTimeout(() => { + if (aborting === aborted) { + finish(aborted, 'interrupted') + } + }, 0) return {} } }, @@ -109,15 +154,17 @@ export function codexTurnLifecycleFake( }) return () => release() }, - start: () => { - if (!picked) { - throw new Error('no picked turn to start') + start: () => open(), + takeInterrupt, + run: () => { + if (!picked || active) { + throw new Error('no picked turn to run') } - active = picked - notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + pickedRuns = true }, end: (status, errorMessage) => { - const turnId = active ?? picked + // A taken interrupt's turn/completed comes ahead of anything a later turn sends. + const turnId = aborting ?? active ?? picked if (!turnId) { throw new Error('no turn to end') } diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 4d828ecf82b..fc1ec0bcb70 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -17,8 +17,7 @@ export function registerCodexAccountHandlers( } return listStaleCodexPanes({ ptyIds: args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'), - settings, - activeHostHomeRoute: codexAccounts.runtimeHomeService.getSelectedHostCodexHomeRoute() + settings }) }) ipcMain.handle('codexAccounts:listRecordedPaneLanes', (_event, args: { ptyIds?: unknown }) => { diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index 353be0f5909..0e109a468e1 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -62,7 +62,7 @@ const SHARED_ROLLOUT = join(MANAGED_SHARED_HOME, 'sessions', '2026', '07', '20', describe('registerPtyHandlers', () => { const { handlers, mainWindow } = setupPtyIpcSuite() - it('records route provenance for a process-wide CODEX_HOME', async () => { + it('records a launch env CODEX_HOME pane under the shared home', async () => { const previousCodexHome = process.env.CODEX_HOME process.env.CODEX_HOME = '/process/custom-codex-home' try { @@ -94,8 +94,7 @@ describe('registerPtyHandlers', () => { expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-process-home', { selectionKey: 'host', accountId: null, - homeRoute: 'shared-home', - environmentHomeOverride: { codexHome: '/process/custom-codex-home' } + homeRoute: 'shared-home' }) } finally { if (previousCodexHome === undefined) { @@ -105,33 +104,6 @@ describe('registerPtyHandlers', () => { } } }) - it('does not guess route provenance for a pane-local environment CODEX_HOME', async () => { - setLocalPtyProvider({ - spawn: vi.fn(async () => ({ id: 'pty-pane-env-home' })), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - shutdown: vi.fn(), - onData: vi.fn(() => vi.fn()), - onExit: vi.fn(() => vi.fn()), - listProcesses: vi.fn(async () => []), - getForegroundProcess: vi.fn(async () => null) - } as never) - const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, getSettings as never) - - await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - env: { CODEX_HOME: '/pane/custom-codex-home' } - }) - - expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-pane-env-home', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - }) it('does not resume under another account when the origin auth stays unavailable', async () => { vi.useFakeTimers() const spawn = vi.fn(async () => ({ id: 'pty-must-not-spawn' })) diff --git a/src/main/ipc/pty-write-ipc-validation.test.ts b/src/main/ipc/pty-write-ipc-validation.test.ts index ee05fc62c60..4217530c464 100644 --- a/src/main/ipc/pty-write-ipc-validation.test.ts +++ b/src/main/ipc/pty-write-ipc-validation.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { readFileSyncMock, spawnMock, recordCodexPaneAccountMock } from './pty-ipc-mock-registry' -import { posixOnlyIt, TEST_CODEX_HOME } from './pty-ipc-test-constants' +import { spawnMock, recordCodexPaneAccountMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -394,47 +393,4 @@ describe('registerPtyHandlers', () => { ensureForDistro.mockRestore() } }) - posixOnlyIt( - 'does not guess route provenance for a pane-local shell startup CODEX_HOME', - async () => { - setLocalPtyProvider({ - spawn: vi.fn(async () => ({ id: 'pty-custom-home' })), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - shutdown: vi.fn(), - onData: vi.fn(() => vi.fn()), - onExit: vi.fn(() => vi.fn()), - listProcesses: vi.fn(async () => []), - getForegroundProcess: vi.fn(async () => null) - } as never) - readFileSyncMock.mockImplementation((path: string) => - path === '/pane-home/.zshrc' ? 'export CODEX_HOME="$HOME/custom-codex-home"\n' : '' - ) - const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) - registerPtyHandlers( - mainWindow as never, - undefined, - () => TEST_CODEX_HOME, - getSettings as never - ) - - await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - env: { - CODEX_HOME: '', - ORCA_CODEX_HOME: '', - HOME: '/pane-home', - SHELL: '/bin/zsh' - } - }) - - expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-custom-home', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - } - ) }) diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index fbc0db2a04d..3b79c0e7876 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -13,11 +13,6 @@ import { } from '../../../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../../../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../../../codex/codex-home-paths' -import { - environmentCodexHomeOverrideContextsEqual, - getCustomCodexHomeOverrideForLaunch, - shellStartupCodexHomeOverrideContextsEqual -} from '../../../codex/codex-real-home-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../../pty/codex-home-wsl-env' import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' import { parseWslPath } from '../../../wsl' @@ -217,47 +212,16 @@ export function recordCodexPaneAccountForSpawn(args: { isReattach: boolean pinnedByResume: boolean launchCodexHomePath: string | null - launchEnv?: NodeJS.ProcessEnv target: CodexAccountSelectionTarget settings: GlobalSettings | undefined }): void { if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { return } - const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) - const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null - const recheckableEnvironmentOverride = - customHomeOverride?.source === 'environment' && - processHomeOverride?.source === 'environment' && - environmentCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const recheckableShellStartupOverride = - customHomeOverride?.source === 'shell-startup' && - processHomeOverride?.source === 'shell-startup' && - shellStartupCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined const record = args.settings ? resolveCodexPaneLaunchAccount({ pinnedByResume: args.pinnedByResume, launchCodexHomePath: args.launchCodexHomePath, - // Why: pane-local overrides cannot be re-derived when a restart builds - // a fresh launch env, so route prompts would guess and block valid input. - recordComparableHomeRoute: - args.pinnedByResume || - ((customHomeOverride?.source !== 'environment' || - recheckableEnvironmentOverride !== undefined) && - (customHomeOverride?.source !== 'shell-startup' || - recheckableShellStartupOverride !== undefined)), - shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, - environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, systemCodexHomePath: getSystemCodexHomePath(), settings: args.settings, target: args.target diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 41752886ab9..bde05540e27 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -103,7 +103,6 @@ export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: P isReattach: ctx.result.isReattach === true, pinnedByResume: ctx.codexResumeHomeSelected, launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: ctx.baseEnv, target: ctx.codexSelectionTarget, settings: ctx.deps.getSettings?.() }) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 293166d8163..e7a6de5e7bc 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -229,7 +229,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { isReattach: ctx.result.isReattach === true, pinnedByResume: ctx.codexResumeHomeSelected, launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: args.env, target: ctx.codexSelectionTarget, settings: ctx.deps.getSettings?.() }) 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 00000000000..063e5d48f82 --- /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 00000000000..acaaf99e0ae --- /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 8c4db243aa7..00000000000 --- 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 bd43623ae49..3e79cf326a5 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 00000000000..2ced3e4b2a9 --- /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 e110b9221c4..00538fe75f9 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 677efc21c4d..649cfccdf1a 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 485e4d78417..3a79762ee2d 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 70efc891283..1921bc82a45 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 855f60ac326..7f9abfc408f 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 ed27ed6fa0b..84fb299fb05 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 dbe15b4709e..ad151d10ec7 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 a38a37fd0c8..3d835c21b73 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 ba6a918cc6e..bdb95a6fc8e 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 787b1aa6491..c61057ac1b9 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 d154e1b29e6..8ae45ca4a31 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', { - scope: 'open-for-read', - sessionId: log.sessionId, - error - }) + 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, + 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 4e64b646824..2c066601ea7 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 ebe2da08a55..6c291c3a378 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 5f85fdded43..66f1f139ea3 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 dc548e2f3dd..a58a1de3bf5 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 fe7b974d77e..4aa27599434 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 83846333300..00000000000 --- 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 78533945671..00000000000 --- 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 623c97445a6..9dcced9944a 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 1d013255b21..b72a24d70bb 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 9276b1a8c05..f0ebac8622b 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 4665a057115..a558252a899 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 00000000000..6c8811eb4a5 --- /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 8eee42a70d0..7b8e9fe5935 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-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index e79958a952c..baba17c0058 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -1,12 +1,15 @@ // What the journal answers about its Stops beyond the queue's pause: the latest Stop event, which -// the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. +// the turn-end rule reads (`journal-stop-turn-end.ts`), whether a person's still decides, and what +// a person's Stop that named no turn binds while and after it settles. import type { JournalReducerState } from './journal-reducer' import { + beginJournalStopSettle, latestAcceptedSendUnopened, personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' +import type { JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -21,7 +24,23 @@ export class JournalStopMarks { } /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ - personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { - return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) + personStopDecides(turnId: string | null, endedAt?: number): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt) + } + + /** `beginJournalStopSettle`: a turn that ends from here until `settled` is the Stop's. */ + beginSettle(): JournalStopSettle | null { + return beginJournalStopSettle(this.deps.state()) + } + + /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one. */ + settled(settle: JournalStopSettle | null, turnId?: string): void { + if (!settle) { + return + } + settle.settling = false + if (turnId !== undefined) { + settle.turnId = turnId + } } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts new file mode 100644 index 00000000000..59ab1bbb990 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts @@ -0,0 +1,90 @@ +// The turn-end rule as a Stop that named no turn binds it: every turn that ends while the Stop +// settles, then only the turn it stopped. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, type JournalReducerState } from './journal-reducer' +import type { JournalStopEvent } from './journal-row-schema' +import { + beginJournalStopSettle, + personStopDecidesTurn, + turnEndAfterStop +} from './journal-stop-turn-end' +import { JournalStopMarks } from './journal-stop-marks' + +const STOPPED_AT = 1_000 + +function stateWith(event?: JournalStopEvent): JournalReducerState { + const state = createJournalReducerState('session-1', 'epoch-1') + state.queuePauseMarks.latestStop = event ? { sequence: 5, event } : null + return state +} + +type TurnBody = Extract + +function ended(turnId: string): TurnBody { + return { kind: 'turn', turnId, state: 'interrupted', completedAt: STOPPED_AT + 10 } +} + +const turnless: JournalStopEvent = { reason: 'user-stop', at: STOPPED_AT } + +describe("a person's Stop that named no turn", () => { + it('makes any turn that ends while it settles theirs', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.beginSettle() + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(personStopDecidesTurn(state, null)).toBe(true) + }) + + it('once settled, binds only the turn it stopped', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(turnEndAfterStop(state, 'other-item', ended('turn-b'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + expect(personStopDecidesTurn(state, 'turn-a')).toBe(true) + }) + + it('binds nothing once it settled having stopped nothing', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + }) + + it('binds nothing before it settles: a relaunch folds the event alone', () => { + const state = stateWith(turnless) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + }) + + it('keeps the turn an earlier press bound when pressed again', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + }) +}) + +describe('a Stop with nothing in memory to bind', () => { + it.each([ + ['one that named its turn', { reason: 'user-stop', turnId: 'turn-a', at: STOPPED_AT }], + ['a host stop', { reason: 'host-stop', at: STOPPED_AT }] + ] satisfies [string, JournalStopEvent][])('opens no settle: %s', (_label, event) => { + expect(beginJournalStopSettle(stateWith(event))).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 4c28a0422a6..6b71477f74e 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -1,11 +1,11 @@ -// What a Stop decides about the turn it named: the one rule every turn-end write passes through. +// What a Stop decides about the turn it stopped: the one rule every turn-end write passes through. // -// A turn a person's Stop or close of this chat named, or, when it named none, a turn opened by a -// send it stopped, ending with no verdict of its own after that Stop's event, ends as their -// cancellation. A host stop, an eviction and no Stop at all leave the end as written. It runs -// where each row is built, inside the journal's serialized write, so it reads every Stop folded -// before the end: the adapter's settle, the host's fallback and a relaunch's settle all write -// through it, and every client folds the row it wrote. +// A turn a person's Stop or close of this chat named, or, when it named none, one that ended while +// that Stop settled or that its settle bound, ending with no verdict of its own after that Stop's +// event, ends as their cancellation. A host stop, an eviction and no Stop at all leave the end as +// written. It runs where each row is built, inside the journal's serialized write: the adapter's +// end, the host's fallback and a relaunch's settle all write through it, and every client folds +// the row it wrote. What an unnamed Stop binds is held in memory, so after a relaunch it binds none. import { agentJournalSubmissionKey, @@ -16,10 +16,9 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { JournalReducerState } from './journal-reducer' import type { JournalStopEvent } from './journal-row-schema' -import type { JournalQueuePauseMarks } from './queued-message-pause' +import type { JournalQueuePauseMarks, JournalStopSettle } from './queued-message-pause' export type JournalLatestStop = NonNullable @@ -40,7 +39,8 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'submissions' | 'aliases' > -/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. */ +/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. + * Only says whether a send's turn has opened; it never decides whose a turn's end is. */ function openingSubmission( state: TurnEndState, userItemId: string | undefined @@ -83,72 +83,36 @@ export function latestAcceptedSendUnopened(state: TurnEndState): boolean { return true } -/** A send a Stop that named no turn stopped: one already handed to the agent at the Stop's - * position, whose turn had not opened. A card the Stop held, or anything sent after it, is not. */ -function isStopTarget( - state: TurnEndState, - stop: JournalLatestStop, - submission: AgentJournalSubmission -): boolean { - if ( - submission.dispatchState === 'rejected' || - (submission.handoverRecorded === true && submission.handedOverAt === undefined) - ) { - return false - } - // A handed-over send's item sits at its handover (`placeHandedOverMessage`). - const handedOver = state.items.get(agentJournalSubmissionKey(submission.clientMessageId)) - return handedOver !== undefined && handedOver.sequence < stop.sequence -} - /** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named - * none and stopped the send that opened it (`userItemId`). */ -function stopIsTurnCancellation( - state: TurnEndState, - stop: JournalLatestStop, - turnId: string, - userItemId: string | undefined -): boolean { + * none and the turn ends while it settles, or its settle bound that turn. */ +function stopIsTurnCancellation(stop: JournalLatestStop, turnId: string): boolean { if (!stopIsAPersons(stop.event.reason)) { return false } if (stop.event.turnId !== undefined) { return stop.event.turnId === turnId } - const opener = openingSubmission(state, userItemId) - return opener !== undefined && isStopTarget(state, stop, opener) + return stop.settle?.settling === true || stop.settle?.turnId === turnId } -/** THE rule: whether the latest Stop makes turn `turnId`, opened by `userItemId` and ending at - * `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw before - * the Stop was news, whenever its end is written. */ +/** THE rule: whether the latest Stop makes turn `turnId`, ending at `endedAt` with no verdict of its + * own, a person's cancellation. An exit the provider saw before the Stop was news, whenever its + * end is written. */ function stopEndsTurnAsCancellation( state: TurnEndState, turnId: string, - userItemId: string | undefined, endedAt: number | undefined ): boolean { const stop = state.queuePauseMarks.latestStop return ( stop !== null && - stopIsTurnCancellation(state, stop, turnId, userItemId) && + stopIsTurnCancellation(stop, turnId) && (endedAt === undefined || endedAt >= stop.event.at) ) } -/** With no turn running, the work in flight is the person's Stop's: every send still unanswered is - * one it stopped. */ -function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestStop): boolean { - const unanswered = [...state.submissions.values()].filter((submission) => - isUnansweredStructuredAgentSessionDispatch(submission) - ) - return ( - unanswered.length > 0 && unanswered.every((submission) => isStopTarget(state, stop, submission)) - ) -} - /** - * Whether a person's Stop decides the end of turn `turnId` (null: the sends in flight with no turn + * Whether a person's Stop decides the end of turn `turnId` (null: the work in flight with no turn * running), by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still * running it is theirs to end. For a writer that must choose before the end is written: a host * stop must not supersede it, and a Claude error result naming no reason leaves its verdict to it. @@ -156,29 +120,36 @@ function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestS export function personStopDecidesTurn( state: TurnEndState, turnId: string | null, - endedAt?: number, - /** The submission that opened the turn, for one whose rows have yet to land. */ - openedBy?: string + endedAt?: number ): boolean { const stop = state.queuePauseMarks.latestStop if (stop === null || !stopIsAPersons(stop.event.reason)) { return false } if (turnId === null) { - return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop) + return stop.event.turnId === undefined && stop.settle?.settling === true } - const turn = [...state.items.values()] - .map((item) => readAgentJournalTurn(item.body)) - .find((candidate) => candidate?.turnId === turnId) - const userItemId = - turn?.userItemId ?? (openedBy === undefined ? undefined : agentJournalSubmissionKey(openedBy)) - return stopEndsTurnAsCancellation(state, turnId, userItemId, endedAt) + return stopEndsTurnAsCancellation(state, turnId, endedAt) +} + +/** Opens the settle of the latest Stop, a person's that named no turn: until it closes, every turn + * that ends is that Stop's. Null when there is none, or it named its turn. */ +export function beginJournalStopSettle( + state: Pick +): JournalStopSettle | null { + const stop = state.queuePauseMarks.latestStop + if (stop === null || stop.event.turnId !== undefined || !stopIsAPersons(stop.event.reason)) { + return null + } + // A repeat press keeps the turn an earlier one bound. + stop.settle = { settling: true, ...(stop.settle?.turnId ? { turnId: stop.settle.turnId } : {}) } + return stop.settle } /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no - * earlier than the latest Stop event, a person's, which named it, or stopped the send that opened - * it, while it was still open (running, or unproven). A provider's own verdict always stands. + * earlier than the latest Stop event, a person's that named it or bound it, while it was still open + * (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( state: TurnEndState, @@ -193,8 +164,7 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - const userItemId = body.userItemId ?? previous?.userItemId - return stopEndsTurnAsCancellation(state, body.turnId, userItemId, body.completedAt) + return stopEndsTurnAsCancellation(state, body.turnId, body.completedAt) ? { ...body, outcome: 'cancellation' } : body } 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 ba879696469..dc270bc97a6 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 c8674e0dbd5..cac1517c409 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 8fce8457b63..d8c44f964bd 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 d7c5e521c82..18cb7efa47a 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 871aca7880d..fcb541b8faa 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 2349524ccfe..855c6ab3e98 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,14 +134,14 @@ export class AgentSessionJournal { this.restore = collaborators.restore } - get isReadOnly(): boolean { - return this.readOnly - } - get epoch(): string { return this.state.epoch } + get agent(): AgentSessionJournalIdentity['agent'] { + return this.identity.agent + } + /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ wroteBeforeOpen(sequence: number | undefined): boolean { @@ -165,16 +152,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 +262,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 +360,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 +375,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 c000ffe9fba..86ff11c54a3 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 00000000000..e56bcc92c96 --- /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 794ca52ab7d..bb965817b59 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 10e709d174f..8da990a4eac 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 638a62bdf02..15911b5e080 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-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 9039760ba43..63dc6823df2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -15,9 +15,13 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' +/** What a person's Stop that named no turn binds, held in memory and never read from a row, so a + * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ +export type JournalStopSettle = { settling: boolean; turnId?: string } + /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - latestStop: { sequence: number; event: JournalStopEvent } | null + latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number } 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 c3910e46685..6c609853d69 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 b780e3d1154..ba9ee8a5453 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 52fc3c86b9b..00000000000 --- 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 42da9a986da..00000000000 --- 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 5ff159ce2e6..6350ba67198 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 c98854292d5..d73cbb7e0bd 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 b5d431c859a..5edc3793d02 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-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index 4709234ad5c..a6b12b4f4fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -10,10 +10,13 @@ import type { ProviderDiagnostic } from '../../../shared/agent-session-failure' /** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. * `turnNotRunning`: its refusal is the kind it gives for a turn not running there, so the Stop keeps - * the child; absent, it could not interrupt that turn, which may run on. */ + * the child; absent, it could not interrupt that turn, which may run on. `turnMayOpen`: no turn + * was there to interrupt, but one a send is owed may still open. `turnId`: the journal turn a Stop + * that took interrupted, which the Stop then binds. */ export type AgentSessionCancelOutcome = { cancelled: boolean - refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true } + refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true; turnMayOpen?: true } + turnId?: string } /** Where a card's Cancel goes: a dismissal (`dismissPrompt`), or the chat's Stop. */ 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 318bada1fe5..9c34c184104 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 90c638c23fb..fe99ad90bc3 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 7a9f758858a..e6498597646 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-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index c89c0fc3d6b..b55ebbda2d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -109,9 +109,17 @@ export function mutateWithChatStop( } return { ok: true, value: { ...named, cancelled: withdrewAny } } } + const reach = hadQueued ? 'unrecorded' : stopReachesUnrecordedWork(ctx, turnId) + const priorStop = ctx.journal.stopMarks.latest() // Issued, not awaited, before the interrupt or any child end; the `finally` awaits it. - const effect = - hadQueued || stopReachesUnrecordedWork(ctx, turnId) ? tookEffect() : Promise.resolve() + const effect = reach === 'unrecorded' ? tookEffect() : Promise.resolve() + // Its settle binds the latest Stop only when that Stop is this press's own, or the one in + // force this press repeats; a late Stop or an event not yet written binds nothing. A write + // is in the fold by its call's return, so a newer latest Stop is this press's event. + const ownsLatestStop = + reach === 'unrecorded' + ? ctx.journal.stopMarks.latest()?.sequence !== priorStop?.sequence + : reach === 'repeat' try { return await performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, @@ -130,7 +138,8 @@ export function mutateWithChatStop( endSession: (owed) => { windDown = owed }, - withdrewQueued: withdrew + withdrewQueued: withdrew, + ...(ownsLatestStop ? { opensSettle: true as const } : {}) } ) } finally { 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 a001eddb3f8..a18b29b8e92 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 73285ee1de4..dc3d4fc42ae 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-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index aab219c779d..6f03cc1345c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -240,7 +240,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it refused because its turn is not running', async () => { @@ -271,7 +273,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it could not interrupt and the child end is unproven', async () => { @@ -295,7 +299,7 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual(["Codex didn't stop: failed to interrupt turn."]) }) - it('reads as requested when the child exit was proven and only a later cleanup step failed', async () => { + it('counts as stopped when the child exit was proven and only a later cleanup step failed', async () => { const { id, result } = send('hello') await result await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) @@ -312,7 +316,8 @@ describe('a Stop that names no turn', () => { fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) }) ) - expect(await statusRows()).toEqual(['Cancellation requested.']) + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the Stop is unconfirmed, not that nothing ran, when neither the interrupt nor the child end is proven', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index f0372c8e58d..639b34cb795 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -38,23 +38,20 @@ import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' +import { + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -type DeadGenerationSubmission = Pick< - ReturnType[number], - 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' -> - -export type DeadGenerationJournal = { +export type DeadGenerationJournal = UnopenedSendJournal & { appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - snapshot: () => Pick, 'items'> pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] - submissions?: () => DeadGenerationSubmission[] } export type StructuredAgentSessionUnfinishedWork = { @@ -139,6 +136,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null + if (!startupFailure) { + await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) + } await (startupFailure ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index bdc07a3b3d5..afcecadc744 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -85,8 +85,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null - journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => - this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false + journalStopDecidesTurn = (turnId: string, endedAt: number): boolean => + this.target?.journal.stopMarks.personStopDecides(turnId, endedAt) ?? false bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 49572abdcc0..12966b73a97 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -142,9 +142,8 @@ export type StructuredAgentSessionEventSink = { /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null /** Whether the bound journal's Stop rule makes turn `turnId`, ending at `endedAt` with no verdict - * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. `openedBy`: - * the submission that opened it, for a turn whose rows have yet to land. */ - journalStopDecidesTurn?(turnId: string, endedAt: number, openedBy?: string): boolean + * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. */ + journalStopDecidesTurn?(turnId: string, endedAt: number): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], 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 c0e78b5a0c5..764d54377d0 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 696236805ac..5da54bebfc5 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-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 60c20cb768d..2c312ef17e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -140,7 +140,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // Judged before the kill: a stop that ends nothing writes nothing. const recorded = (await stopEndsWork(context, sessionId, session, ending)) ? recordStopEvent(context, sessionId, session, ending) - : Promise.resolve() + : Promise.resolve(null) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. @@ -233,6 +233,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } } throw error + } finally { + // A person's close binds what its child's end cut; done, proven or not, it binds no more. + void recorded.then((settle) => session.journal.stopMarks.settled(settle)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 757a76a9059..70cfd07bb7a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -7,6 +7,8 @@ import type { StructuredAgentSessionStopCause } from './structured-agent-session import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' +import { sentSinceStop } from './structured-agent-session-queued-stop' /** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its * own step (`recorded` names its reason); any other stop names the reason its event records, with @@ -63,23 +65,37 @@ export async function stopEndsWork( journal.submissions(), child.fence ) - // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. + return working && (ending.cause === 'user-close' || !defersToPersonsStop(session)) +} + +/** A host stop of work a person's Stop is already ending must not supersede that Stop's reason: + * the turn it decides, or, with no turn running, the work under the queue pause it still holds, + * with nothing sent since, which a host's event would lift. */ +function defersToPersonsStop(session: StructuredAgentSessionHostSession): boolean { + const { journal } = session + const live = journal.activeTurnId() + if (live !== null) { + return journal.stopMarks.personStopDecides(live) + } + const inForce = journal.queuedMessages.userStopInForce() return ( - working && - (ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId())) + (inForce !== null && !sentSinceStop(journal, inForce)) || + journal.stopMarks.personStopDecides(null) ) } /** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: - * bookkeeping, reported on failure. */ + * bookkeeping, reported on failure. A person's close that named no turn opens its settle once its + * event lands, as a person's Stop does, so a turn the child's end cuts is theirs; the caller + * closes it once that end is done. */ export function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, session: StructuredAgentSessionHostSession, ending: StructuredAgentSessionStopEnding -): Promise { +): Promise { if ('recorded' in ending) { - return Promise.resolve() + return Promise.resolve(null) } const turnId = session.journal.activeTurnId() return session.journal @@ -88,12 +104,14 @@ export function recordStopEvent( structuredAgentSessionConversationFence(context.deps.store, sessionId) ) .then( - () => undefined, - (error: unknown) => + () => (ending.cause === 'user-close' ? session.journal.stopMarks.beginSettle() : null), + (error: unknown) => { context.deps.logger.warn("a host stop's Stop event row skipped", { scope: 'stop-event', sessionId, error }) + return null + } ) } 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 f74532ac9ae..763df3080c3 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 6552f8a2601..ae75f476591 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 5b5a4d19c3c..8256f90e53c 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 00000000000..f3ce31243da --- /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 3fed3c96291..f98959bc0ad 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 176fd8e11fb..248534097b8 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 2dd2f567b87..eeaafbbaff8 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-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index ccdc429e34e..03c1042309f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -58,30 +58,42 @@ export async function runRecordedStop( } /** - * Whether a Stop reaching a running agent stops anything no Stop event records yet. Not when it - * names a turn already over (a late Stop from a phone), nor when it repeats the Stop still in force - * with nothing sent since, on the same turn or one that opened after a Stop pressed before any - * turn showed: a card queued between the presses then sends normally, as after one Stop. + * What a Stop reaching a running agent stops: work no Stop event records yet (`unrecorded`), or + * only what the Stop still in force already records, which it repeats with nothing sent since, on + * the same turn or one that opened after a Stop pressed before any turn showed (`repeat`): a card + * queued between the presses then sends normally, as after one Stop. `late`: it names a turn + * already over, as a late Stop from a phone can. */ export function stopReachesUnrecordedWork( ctx: Pick, namedTurnId: string | undefined -): boolean { +): 'unrecorded' | 'repeat' | 'late' { const live = ctx.journal.activeTurnId() // No turn published yet while the agent works: the named one may still be opening. if ( structuredAgentSessionStopNamesTurnNotLive(namedTurnId, live) && (live !== null || !isMainAgentWorking(ctx)) ) { - return false + return 'late' } const inForce = ctx.journal.queuedMessages.userStopInForce() if (inForce === null) { - return true + return 'unrecorded' } - // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it - // back to waiting, so this Stop must hold it. A send with no sequence is an older host's. - const sentSince = ctx.journal + // This interrupt may send a later send back to waiting, so this Stop must hold it. + return sentSinceStop(ctx.journal, inForce) || + structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) + ? 'unrecorded' + : 'repeat' +} + +/** Whether anything was sent after the Stop in force and not refused, even if its fate is + * unknown. A send with no sequence is an older host's. */ +export function sentSinceStop( + journal: Pick, + inForce: { sequence: number } +): boolean { + return journal .submissions() .some( (entry) => @@ -89,7 +101,6 @@ export function stopReachesUnrecordedWork( entry.acceptedSequence !== undefined && entry.acceptedSequence > inForce.sequence ) - return sentSince || structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) } /** The Stop's withdrawal of every queued send, issued at once and never awaited ahead of the 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 a0d3dbe7b52..6fd6bb13612 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 945f2cefbad..6f59e5e7d5e 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 8f110c3e69a..3c3cc124003 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 d5899c794e3..fefd91a8478 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 74e4cfb4815..bc40f51c466 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 b16f76c1633..93986d5988b 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 973d7758358..8f846bb6f1a 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 c748748ccd4..1a66b556103 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 63722d1737c..f69e9ced6dc 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 00000000000..e2f2087d442 --- /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 95c97be63a9..4b458002ab9 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 441d4e80626..7df838d04e8 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 29ea17845d8..6c6efc49d72 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 ef8481e2b1f..83dedb1709d 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 3c91a9732b9..9df4e392bc3 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 93c33146743..9f00c69aca4 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 2284367c14b..1c17dc11e30 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 a2ed07db291..b16c7277fc1 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 3046aa971f6..c95d8f6d7ba 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 dc8eb111700..5a359deebad 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 f95579d7750..ee9c69bf38c 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 f14393df874..429cb803794 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 b71022b92c7..caabf348133 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 743a8e2287c..6409c544129 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 04be126d418..24581c8fca2 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 04ce86d36bc..87b440ef37a 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-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 2654b20ac73..a91ed7c8cbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -1,9 +1,9 @@ -// Which turn a person's Stop that named no turn binds: only a turn a send it stopped opens. A Stop -// of a start that never landed stopped a send that opens no turn; a card it held, which Resume -// releases, and anything sent after it open their own; a rewind keeps the binding. Turn rows name -// the send that opened them, as Codex writes them. +// Which turn a person's Stop that named no turn binds: one that ends while the Stop settles, and +// the turn its interrupt took. A Stop that stopped nothing binds no turn that opens after it, nor +// does a Stop of a start that never landed; a card it held, which Resume releases, and anything +// sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -12,10 +12,11 @@ import { import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, eventually, + QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -63,6 +64,18 @@ async function laterTurn() { .find((turn) => turn?.turnId === 'turn-later') } +/** Every end row written for the later turn, in order: a relabel would show as two. */ +function laterTurnEndRows() { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => { + const turn = row.kind === 'item' ? readAgentJournalTurn(row.body) : undefined + return turn?.turnId === 'turn-later' && turn.state !== 'running' ? [turn] : [] + }) +} + /** The turn send `clientMessageId` opens, running, named by its row as Codex writes it. */ async function turnOpenedBy(clientMessageId: string, state: 'running' | 'interrupted' = 'running') { await journal().appendItem( @@ -185,19 +198,22 @@ describe('a Stop of a start that never landed binds no later turn', () => { }) }) -describe('a Stop pressed before its send opened a turn binds only that turn', () => { +describe('a Stop pressed before its send opened a turn binds only the turn it stopped', () => { /** The send the Stop stopped is handed over and unopened; a card waits behind it. */ - async function stopBeforeTheTurnShowed(): Promise<{ stopped: string; held: string }> { + async function stopBeforeTheTurnShowed( + answer: Awaited> = { cancelled: true } + ): Promise<{ stopped: string; held: string }> { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() const held = await queuedDraft('queued behind the turn') + rig.cancelTurn.mockResolvedValueOnce(answer) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') return { stopped, held } } - it("binds the stopped send's own turn", async () => { - const { stopped } = await stopBeforeTheTurnShowed() + it('binds the turn its interrupt took, whose end lands after the Stop answered', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: true, turnId: 'turn-later' }) await rig.settleAccepted(stopped, 'stopped') await turnOpenedBy(stopped, 'interrupted') @@ -205,6 +221,34 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) + // The interrupt reaches the turn as it opens; its end is written before the Stop answers. + it('reads a turn that ends while the Stop settles as interrupted from its first end row', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + return { cancelled: true, turnId: 'turn-later' } + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(laterTurnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(journal().activeTurnId()).toBeNull() + }) + + it('binds no turn that opens after a Stop that stopped nothing', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: false }) + await rig.settleAccepted(stopped, 'stopped') + + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + + await expectNews() + }) + it("writes the host's event when it evicts the turn of the card Resume sent", async () => { const { stopped, held } = await stopBeforeTheTurnShowed() await rig.settleAccepted(stopped, 'stopped') @@ -229,14 +273,116 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () }) }) -describe('a host stop with no turn running after a Stop that named none', () => { - it('writes nothing while every unanswered send is one the Stop stopped', async () => { +describe('a press that writes no Stop event of its own', () => { + // A turnless Stop long since settled, then a later turn the person's own send opened. + async function laterTurnAfterAnEarlierStop(): Promise { rig = await createQueuedMessageTestRig() await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turnOpenedBy(sent) + return sent + } + + it('reopens no earlier Stop: a late Stop naming a turn already over binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + const late = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: 'turn-gone' }, 'agentSession.cancel', hostTestOperationId()), + turnId: 'turn-gone' + }) + + expect(late).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) + + // The event is in the fold by its write's return, so one that failed leaves the earlier Stop latest. + it('reopens no earlier Stop: a press whose event row failed binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + vi.spyOn(journal(), 'appendStopEvent').mockRejectedValueOnce(new Error('disk full')) + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) +}) + +describe("a Stop's settle that ends the turn its interrupt took", () => { + it("ends a turn still running at the Stop's settle, as the Stop's, once", async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + await rig.settleAccepted(stopped, 'stopped') + await turnOpenedBy(stopped) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-later' }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(journal().activeTurnId()).toBeNull() + }) +}) + +describe('a host stop with no turn running after a Stop that named none', () => { + it('defers to that Stop while it settles, and not after', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + let whileSettling: boolean | undefined + rig.cancelTurn.mockImplementationOnce(async () => { + whileSettling = journal().stopMarks.personStopDecides(null) + return { cancelled: true } + }) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(whileSettling).toBe(true) + expect(journal().stopMarks.personStopDecides(null)).toBe(false) + }) + + it.each([ + ['stopped nothing', { cancelled: false }], + ['took', { cancelled: true }] + ] as const)( + 'writes nothing while the Stop, which %s, still pauses the queue, and the card stays held', + async (_, answer) => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce(answer) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() + } + ) + + // Codex could not reach a turn still able to open, and its process could not be ended. + it('writes nothing after a Stop whose kill failed, and the card stays held', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() }) it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 50056eedd03..916654cac64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -1,7 +1,7 @@ -// A Stop that took effect still decides its turn after Orca restarts before the turn's end was -// written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" -// with the muted mark, not "Failed". A turn nobody stopped, and one a Stop never named, still read -// as the news they are. +// A Stop that took effect still decides the turn it named after Orca restarts before the turn's end +// was written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" +// with the muted mark, not "Failed". A turn nobody stopped, one a Stop never named, and one a +// turnless Stop was still settling when Orca died, still read as the news they are. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -138,6 +138,26 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) + // A Stop naming its turn, as the phone sends one: its event names the turn, which is what a + // relaunch reads, so it still counts though Orca died with its interrupt out. + it.each([ + ['Claude', CLAUDE_TURN], + ['Codex', CODEX_TURN] + ])('reads Interrupted after N for a %s Stop that named its turn', async (_label, identity) => { + await runningTurn(identity) + rig.cancelTurn.mockImplementationOnce(() => new Promise(() => undefined)) + void rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: TURN }, 'agentSession.cancel', hostTestOperationId()), + turnId: TURN + }) + await expect.poll(() => journal().stopMarks.latest()?.event.turnId).toBe(TURN) + + await restartAndSettle() + + expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(settled().label).toMatch(/^Interrupted after /) + }) + it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { await runningTurn(CODEX_TURN) // The host dies inside the close: the provider's close never answers, and nothing settles. @@ -273,10 +293,11 @@ describe('a restart between a Stop and its turn end', () => { expect(settled().turn).not.toHaveProperty('outcome') }) - // A Stop pressed before any turn showed stopped the turn its send was about to open, and no other. + // A Stop pressed before any turn showed stopped the turn its interrupt took, and no other. it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: TURN }) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') @@ -323,6 +344,35 @@ describe('a restart between a Stop and its turn end', () => { expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome') }) + // What a turnless Stop binds is held in memory: a relaunch finds it unsettled, and it binds none. + it('reads the turn a turnless Stop was settling when Orca died as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + // Orca dies while the interrupt is out: the Stop never settles. + rig.cancelTurn.mockImplementationOnce(async () => { + await journal().appendItem( + CODEX_TURN, + { + kind: 'turn', + turnId: TURN, + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(stopped) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return new Promise(() => undefined) + }) + void rig.stop() + await expect.poll(() => journal().activeTurnId()).toBe(TURN) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + + await restartAndSettle('exit-observed') + + expect(settled(TURN).turn).toMatchObject({ state: 'interrupted' }) + expect(settled(TURN).turn).not.toHaveProperty('outcome') + }) + // A refusal is no record: the first Stop stays the one in force, so pressing again repeats it. it('writes nothing for a Stop pressed again after the provider refused one', async () => { await runningTurn(CODEX_TURN) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts new file mode 100644 index 00000000000..a3c8853ef67 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -0,0 +1,148 @@ +// A Stop that ends the child after its interrupt failed: its row reports on the run it stopped, so +// one whose child end took back the send it found, with no turn running, leaves none. Its message +// is back in the composer, and a row would sit under the turn before as if that turn were stopped. + +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 { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string | null = null +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function stopEndingTheChild(options: { + turnRunning: boolean + takesSendBack: boolean + earlierProcessSendInDoubt?: boolean +}) { + root = await mkdtemp(join(tmpdir(), 'orca-stop-note-withdrawn-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + if (options.turnRunning) { + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + if (options.earlierProcessSendInDoubt) { + await journal.appendSubmission({ + clientMessageId: 'send-0', + payloadFingerprint: 'send-0', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'before the crash' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-0', + state: 'unknown', + reason: 'host_restarted', + fence: 1, + recovered: true + }) + } + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + fence: 1 + }) + const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: 1, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + closeSession: vi.fn(), + cancelTurn: vi.fn(async () => ({ + cancelled: false, + refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' as const } } + })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + const result = await performCancel(ctx, { + clientOperationId: 'stop-1', + stopChild: async () => { + if (options.takesSendBack) { + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }), + fence: 1, + recovered: true + }) + } + } + }) + const rows = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + return { cancelled: result.ok && result.value.cancelled, rows } +} + +describe('a Stop that ends the child after its interrupt failed', () => { + it('leaves no row when the child end took back the send, with no turn running', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: true })).toEqual({ + cancelled: true, + rows: [] + }) + }) + + it('says cancellation was requested when the send stays in doubt', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: false })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) + + // That send was never this child's to take back, so it does not keep the row. + it("leaves no row when the only other send in doubt was an earlier process's", async () => { + expect( + await stopEndingTheChild({ + turnRunning: false, + takesSendBack: true, + earlierProcessSendInDoubt: true + }) + ).toEqual({ cancelled: true, rows: [] }) + }) + + it('says cancellation was requested on the turn it stopped, whatever became of the send', async () => { + expect(await stopEndingTheChild({ turnRunning: true, takesSendBack: true })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts new file mode 100644 index 00000000000..14e62854bbc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -0,0 +1,197 @@ +// A Stop pressed before its send's turn showed binds the turn that send opens only by stopping it: +// a Codex turn that did not open in time ends with the child the Stop ends, and a Claude turn that +// opens before the Stop's child end is proven ends as the Stop's. Nothing reads as running after. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +const LATER: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-later', + ordinal: 1000 +} + +const OPENED: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-opened', + ordinal: 999 +} + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** The turn `clientMessageId` opens, running, as the provider writes it. */ +async function turnOpens(clientMessageId: string): Promise { + await journal().appendItem( + OPENED, + { + kind: 'turn', + turnId: 'turn-opened', + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function openedTurn() { + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-opened') +} + +function nothingRuns(): boolean { + return !isStructuredAgentSessionMainAgentWorking( + journal().activeTurnId(), + journal().submissions() + ) +} + +/** Drains the Stop's next step on the session's lane: a wind-down it handed the child's end to. */ +async function windDown(): Promise { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + +describe('a Codex Stop whose answered turn did not open in time', () => { + // Codex admitted the send, answered into a turn it has not opened; the Stop's wait ran out. + async function stopOfAnsweredSend(): Promise { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + return sent + } + + it('ends the child, so the turn can never run', async () => { + await stopOfAnsweredSend() + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toBeUndefined() + expect(nothingRuns()).toBe(true) + }) + + it('reads a turn that opened before the child end as interrupted by the Stop', async () => { + const sent = await stopOfAnsweredSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) + +describe('a Codex Stop that could not reach a turn still able to open, whose kill failed', () => { + it('says the Stop is unconfirmed, never that no turn was running', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + const rows = journal() + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.failure?.kind] : [])) + expect(rows).toEqual(['cancelUnconfirmed']) + }) +}) + +describe('a Claude Stop pressed before its send echoed', () => { + it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await windDown() + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) + +describe("a person's close pressed before its send's turn showed", () => { + it('reads the turn its child end cut as interrupted by the person', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + const turn = items + .map((item) => readAgentJournalTurn(item.body)) + .find((entry) => entry?.turnId === 'turn-opened') + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + // A /clear stops the agent as the person's close and keeps the conversation, so the close's + // settle must have closed by the time a later turn ends. + it('binds no turn that ends on its own after a close the conversation outlived', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + await journal().appendItem( + LATER, + { kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal().appendItem( + LATER, + { + kind: 'turn', + turnId: 'turn-later', + state: 'interrupted', + startedAt: Date.now(), + completedAt: Date.now() + 5 + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const later = journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-later') + expect(later).toMatchObject({ state: 'interrupted' }) + expect(later).not.toHaveProperty('outcome') + }) +}) 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 3881e8c429f..27ae0cb0608 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 1b7bd87a9f2..a61d5a57240 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 1f8fc80c863..5a1ad69f613 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 a90a65c1c83..84339a7587d 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-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 5e26d1b620f..6337d65f553 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -2,7 +2,11 @@ import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalStatusItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' @@ -22,6 +26,8 @@ import { structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' +import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Whether the fold reads working. Every write has landed by its call's return, and the open paid @@ -52,25 +58,46 @@ function stillRunsStoppedTurn( return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId } -/** The row for a Stop the provider declined, in its words when it gave any. */ +/** Whether the child's end took back every send the Stop found in flight, none of them having run + * (`withdrawCodexSendsNoTurnOpenedFor`). */ +function tookBackEverySend( + ctx: Pick, + inFlight: readonly AgentJournalSubmission[] +): boolean { + const rejected = new Set( + ctx.journal + .submissions() + .flatMap((entry) => (entry.dispatchState === 'rejected' ? [entry.clientMessageId] : [])) + ) + return inFlight.length > 0 && inFlight.every((entry) => rejected.has(entry.clientMessageId)) +} + +/** The row for a Stop the provider declined, in its words when it gave any. One that could not + * reach a turn still able to open says the Stop is unconfirmed, never that nothing ran. */ function stopRefusedNote( ctx: Pick, refusal: AgentSessionCancelOutcome['refusal'] ): AgentJournalStatusItem { const detail = refusal?.detail + const fact = refusal?.turnMayOpen + ? agentSessionFailureFact('cancelUnconfirmed') + : agentSessionFailureFact('stopRefused', detail ? { detail } : {}) return { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('stopRefused', detail ? { detail } : {}), { - ...ctx.failureTextContext, - surface: 'row' - }) + ...agentSessionFailureWords(fact, { ...ctx.failureTextContext, surface: 'row' }) } } /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went - * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ -export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; stoppedAt: number } + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. + * `settled` closes the Stop's settle once the wind-down finishes, whether or not the child's exit + * was proven: until then, what ends is the Stop's. */ +export type StructuredAgentSessionStopWindDown = { + waitsForProvider: boolean + stoppedAt: number + settled?: () => void +} /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent @@ -91,35 +118,45 @@ export async function endStoppedStructuredAgentSession( await stopChild() } catch (error) { onError(error) + } finally { + windDown.settled?.() } } +/** A turn the Stop's interrupt took that still reads running: the Stop ends it, once, while it + * still binds it. The provider's stream lands as it arrives, so the fold already holds any end it + * sent. Bookkeeping: a failure is reported, never the Stop's. */ +async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: string): Promise { + try { + const running = ctx.journal + .snapshot() + .items.filter((item) => readAgentJournalTurn(item.body)?.turnId === turnId) + const mutations = runningTurnLifecycleRevisions(running, { + state: 'interrupted', + completedAt: ctx.now() + }) + if (mutations.length > 0) { + await ctx.journal.appendLifecycleBatch({ + settlementId: `stop-settled:${turnId}`, + mutations, + fence: ctx.fence + }) + } + } catch (error) { + ctx.logger.warn("ending a stopped turn at its Stop's settle failed", { + scope: 'stop-settle', + sessionId: ctx.sessionId, + error + }) + } +} + +/** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ +type StopSettleBinding = { turnId?: string; closedByWindDown?: true } + export async function performCancel( ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ - turnId?: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - /** Ends the provider child, for a running command the provider did not take the Stop on, or a - * turn whose interrupt failed. */ - stopChild?: () => Promise - /** A child end after a failed interrupt that threw. */ - onStopChildError?: (error: unknown) => void - /** After that throw: whether the host let go of the child, its exit proven before a later - * cleanup step failed. */ - childReleased?: () => boolean - /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends - * its session. */ - endSession?: (windDown: StructuredAgentSessionStopWindDown) => void - /** Whether the host's withdrawal of queued messages for this Stop withdrew any; awaited only - * after the interrupt. */ - withdrewQueued?: Promise - /** The session's child records: a background Stop reaches the tasks they offer a stop. */ - childWork?: () => readonly AgentChildWorkView[] | undefined - } + input: PerformCancelInput ): Promise> { if (input.prompt) { const validated = validatePendingPrompt(ctx, input.prompt) @@ -127,6 +164,52 @@ export async function performCancel( return answerCancelOfSettledPrompt(ctx, { ...input, prompt: input.prompt }, validated) } } + // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). + const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null + const binding: StopSettleBinding = {} + const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) + try { + return await cancelAndNote(ctx, input, binding, close) + } finally { + if (!binding.closedByWindDown) { + close() + } + } +} + +type PerformCancelInput = { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on, or a + * turn whose interrupt failed. */ + stopChild?: () => Promise + /** A child end after a failed interrupt that threw. */ + onStopChildError?: (error: unknown) => void + /** After that throw: whether the host let go of the child, its exit proven before a later + * cleanup step failed. */ + childReleased?: () => boolean + /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends + * its session. */ + endSession?: (windDown: StructuredAgentSessionStopWindDown) => void + /** Whether the host's withdrawal of queued messages for this Stop withdrew any; awaited only + * after the interrupt. */ + withdrewQueued?: Promise + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined + /** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */ + opensSettle?: true +} + +async function cancelAndNote( + ctx: AgentSessionTurnContext, + input: PerformCancelInput, + binding: StopSettleBinding, + closeSettle: () => void +): Promise> { let cancelled = false let note: AgentJournalStatusItem | null = { kind: 'status', @@ -155,11 +238,15 @@ export async function performCancel( const endsSession = input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true const stoppedAt = Date.now() + // Read before the cancel: the sends a child end may take back. + const sentBeforeStop = ctx.journal.submissions().filter(sendStopCanTakeBack) // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined // The provider could not interrupt the turn, or its cancel threw: the turn may run on. let interruptFailed = false let refusal: AgentSessionCancelOutcome['refusal'] + // The turn the provider says this Stop's interrupt took. + let stoppedTurn: string | undefined try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const outcome: AgentSessionCancelOutcome = stoppedBefore @@ -187,6 +274,7 @@ export async function performCancel( taken = outcome.cancelled cancelled = outcome.cancelled refusal = outcome.refusal + stoppedTurn = outcome.turnId interruptFailed = refusal !== undefined && refusal.turnNotRunning !== true // Working is read first: a working session never takes this branch, so a child end below never // waits on the withdrawal. On an idle queue the withdrawal has already landed in the fold. @@ -217,7 +305,8 @@ export async function performCancel( if (endsSession && (!namesTurnNotLive || taken !== false)) { // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. - input.endSession?.({ waitsForProvider: taken === true, stoppedAt }) + binding.closedByWindDown = true + input.endSession?.({ waitsForProvider: taken === true, stoppedAt, settled: closeSettle }) cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { @@ -246,7 +335,13 @@ export async function performCancel( } if (ended) { cancelled = true - note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + // A child end that took back every send it found, with no turn running, ended a run that + // never started: its message is back in the composer, and a row would sit under the turn + // before as if that turn were stopped. + note = + stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop) + ? null + : { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } else if (taken !== undefined) { // The turn was just read running, so a named Stop says it was refused rather than nothing. note = stopRefusedNote(ctx, refusal) @@ -255,6 +350,12 @@ export async function performCancel( // Nothing was left of the turn it named and nothing else ended: a Stop that ends nothing writes no row. note = null } + if (cancelled) { + binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined + } + if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { + await endStoppedTurnAtSettle(ctx, stoppedTurn) + } const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } if (input.scope || note === null) { return { ok: true, value } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts new file mode 100644 index 00000000000..d4a78117a1e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts @@ -0,0 +1,107 @@ +// Which sends a Stop's child end takes back: the same rule decides the withdrawal and whether the +// Stop writes a row, so a send it must leave alone is left alone by both. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + sendStopCanTakeBack, + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' + +type Send = Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' +> + +/** A send that started its own turn before a person's Stop at sequence 10, no turn having opened. */ +function sendBeforeStop(overrides: Partial = {}): Send { + return { + clientMessageId: 'send-1', + dispatchState: 'pending', + handoverRecorded: true, + handedOverAt: 5, + acceptedSequence: 4, + ...overrides + } +} + +function journalWith(send: Send) { + const resolveDispatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 11 })) + const journal: UnopenedSendJournal = { + agent: 'codex', + queuedMessages: { + userStopInForce: () => ({ sequence: 10, event: { reason: 'user-stop', at: 1 } }) + }, + snapshot: () => ({ + items: [ + { + itemId: agentJournalSubmissionKey(send.clientMessageId), + revision: 0, + sequence: 5, + observedAt: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + turnScope: { kind: 'thread' } + } + ] + }), + submissions: () => [send], + resolveDispatch + } + return { journal, resolveDispatch } +} + +describe('a send a Stop can take back', () => { + it('is one still being handed over, or one this process lost the answer to', () => { + expect(sendStopCanTakeBack(sendBeforeStop())).toBe(true) + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown' }))).toBe(true) + }) + + // An earlier process's send: its child may have started it before the crash. + it('is never one an earlier process left in doubt', () => { + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown', recovered: true }))).toBe( + false + ) + }) + + // The card holds its text and returns paused; nothing was handed to the agent. + it("is never a queued card's send that was not handed over", () => { + expect(sendStopCanTakeBack(sendBeforeStop({ handedOverAt: undefined }))).toBe(false) + }) +}) + +describe("a Codex child's end under a person's Stop", () => { + it('withdraws a send whose answer this process lost', async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ dispatchState: 'unknown' })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'send-1', state: 'rejected' }) + ) + }) + + it('leaves a send an earlier process left in doubt as it is', async () => { + const { journal, resolveDispatch } = journalWith( + sendBeforeStop({ dispatchState: 'unknown', recovered: true }) + ) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) + + it("leaves a queued card's send as it is", async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ handedOverAt: undefined })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts new file mode 100644 index 00000000000..64a5296c008 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -0,0 +1,94 @@ +// Which unanswered sends a dying Codex child takes with it as never sent, read from the journal at +// the settlement that lands, so a retried wind-down reads the same rows. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentType } from '../../../shared/agent-status-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** What the derivation reads; absent members (a narrow double) withdraw nothing. */ +export type UnopenedSendJournal = { + agent?: AgentType + queuedMessages?: Pick + snapshot: () => Pick, 'items'> + submissions?: () => Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' + >[] + resolveDispatch?: AgentSessionJournal['resolveDispatch'] +} + +/** A send a dying child can take back: one still being handed over, or one this process left in + * doubt (its answer was lost). Never a queued card's, nor a send an earlier process left behind. */ +export function sendStopCanTakeBack( + entry: Pick< + AgentJournalSubmission, + 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' + > +): boolean { + return ( + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ) +} + +/** + * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were + * sent, ends it: each one that started its own turn (handed over with no turn running, so its + * message belongs to no turn) when no turn has opened since. Codex records a prompt only once its + * turn starts (core tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send that + * joined a running turn may be in it, so it, a send with no recorded place, and any other end stay + * in doubt. + */ +export async function withdrawCodexSendsNoTurnOpenedFor( + journal: UnopenedSendJournal, + fence: number +): Promise { + const stop = journal.agent === 'codex' ? journal.queuedMessages?.userStopInForce() : null + if (!stop || !journal.submissions || !journal.resolveDispatch) { + return + } + const { items } = journal.snapshot() + const turns = items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] + }) + // Its message's place, recorded at handover: the turn it joined, or the conversation. + const startedOwnTurn = (clientMessageId: string): boolean => + items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId))?.turnScope + ?.kind === 'thread' + const opened = (acceptedSequence: number): boolean => + turns.some((turn) => turn.running || turn.sequence > acceptedSequence) + const unopened = journal + .submissions() + .filter( + (entry) => + sendStopCanTakeBack(entry) && + entry.acceptedSequence !== undefined && + entry.acceptedSequence < stop.sequence && + startedOwnTurn(entry.clientMessageId) && + !opened(entry.acceptedSequence) + ) + const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + for (const entry of unopened) { + await journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...withdrawn, + fence, + recovered: true + }) + } +} 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 00000000000..170f58eba2f --- /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 11647f2eefd..217360673fc 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 9de1925791f..c110a935aea 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 99489b18769..f20ed4879c6 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 5b47be7f9f8..43a2beee24c 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 6496518b50b..f3e72578709 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/persistence-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts index 14ac4de0a12..5fce5868848 100644 --- a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts +++ b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts @@ -9,7 +9,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' -import { findTerminalTabIdForLeaf } from './runtime/workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from './persistence/terminal-topology/terminal-topology-membership' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts new file mode 100644 index 00000000000..27aa67c7d6e --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -0,0 +1,133 @@ +/** + * Ratchet: outside `persistence/terminal-topology/`, only the files listed here may reference a + * named layout writer. Blind to in-place mutation of the object `getWorkspaceSession` returns. + */ +import { resolve } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../../shared/source-scan/source-tree-scan' + +const MAIN_ROOT = resolve(__dirname, '../..') +const BOUNDARY_DIR = 'persistence/terminal-topology/' + +/** + * Files (relative to `src/main`) outside the boundary referencing each writer; each routing change + * deletes its own rows. Not listed: lifecycle writers (repo/worktree removal, identity rekey) and + * SSH lease marks that drop a dead PTY's binding; they delete or rekey layout, never choose it. + */ +const ALLOWED_REFERENCES: Record = { + terminalSurfaceCloseMutation: [], + persistPtyBinding: [ + 'ipc/pty/ipc/spawn-commit-persist.ts', + 'ipc/pty/pane/stable-owner.ts', + 'ipc/pty/runtime/spawn-commit.ts', + 'ssh/ssh-relay-session.ts' + ], + // Several runtime files only check it exists, then write through setWorkspaceSessionForWorktree. + setWorkspaceSession: [ + 'ipc/pty/pane/stable-owner.ts', + 'ipc/session.ts', + // Store-internal: patchWorkspaceSession -> setWorkspaceSession. + 'persistence/loading-store/session-snapshot-operations.ts', + // Test support: seeds sessions for the acknowledged-tab retirement audit. + 'runtime/acknowledged-terminal-tab-retirement-fixture.ts', + 'runtime/client-hosted-browser-page-persistence.ts', + 'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts', + 'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts', + 'runtime/orca-runtime-attach-window.ts', + 'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts', + 'runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'runtime/orca-runtime-persist-headless-session-tab-props.ts', + 'runtime/orca-runtime-persist-headless-terminal-title.ts', + 'runtime/orca-runtime-persist-terminal-surface-retirements.ts', + 'runtime/orca-runtime-pty-foreground-process-reads.ts', + 'runtime/orca-runtime-stop-terminals-for-worktree.ts', + 'runtime/runtime-legacy-worker-terminal-recovery-persistence.ts', + 'runtime/runtime-workspace-session-controller.ts' + ], + // The partition sinks under setWorkspaceSession and stageWorkspaceSessionBeforeUnload. + setLocalWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], + setHostWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], + // The runtime's session controller, reachable from every OrcaRuntime mixin. + setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], + patchWorkspaceSession: ['ipc/session.ts'], + stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'], + setWorkspaceSessionForWorktree: [ + 'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts', + 'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts', + 'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts', + 'runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'runtime/orca-runtime-persist-headless-session-tab-props.ts', + 'runtime/orca-runtime-persist-headless-terminal-title.ts', + 'runtime/orca-runtime-pty-foreground-process-reads.ts' + ] +} + +/** A writer's own definition (a function or class member), as opposed to any other mention. */ +function isDefinitionName(node: ts.Node): boolean { + const parent = node.parent + return ( + (ts.isFunctionDeclaration(parent) || + (ts.isClassElement(parent) && ts.isClassLike(parent.parent))) && + parent.name === node + ) +} + +function isTypeOnlyImportOrExport(node: ts.Node): boolean { + return ( + ((ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) && node.isTypeOnly) || + (ts.isImportClause(node) && node.isTypeOnly) || + (ts.isExportDeclaration(node) && node.isTypeOnly) + ) +} + +/** Any name or string literal except the writer's own definition; types are skipped. */ +function referencedName(node: ts.Node): string | undefined { + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) { + return isDefinitionName(node) ? undefined : node.text + } + return undefined +} + +function referencingFilesByWriter(): Map> { + const writers = Object.keys(ALLOWED_REFERENCES) + const references = new Map(writers.map((writer) => [writer, new Set()])) + for (const file of scanSourceTree(MAIN_ROOT)) { + // Why prefilter: parsing every main-process file would dominate the test's budget. A matched + // name appears verbatim in the text unless it is spelled with an escape. + if ( + file.relativePath.startsWith(BOUNDARY_DIR) || + (!file.source.includes('\\') && !writers.some((writer) => file.source.includes(writer))) + ) { + continue + } + const source = ts.createSourceFile(file.relativePath, file.source, ts.ScriptTarget.Latest, true) + const visit = (node: ts.Node): void => { + // Why: ExpressionWithTypeArguments is a type node but also holds `extends f(x)` and `x`. + const typeOnly = + (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) || + ts.isInterfaceDeclaration(node) || + isTypeOnlyImportOrExport(node) + if (typeOnly) { + return + } + const name = referencedName(node) + if (name !== undefined) { + references.get(name)?.add(file.relativePath) + } + ts.forEachChild(node, visit) + } + visit(source) + } + return references +} + +describe('terminal topology boundary ratchet', () => { + const references = referencingFilesByWriter() + + for (const [writer, allowed] of Object.entries(ALLOWED_REFERENCES)) { + it(`only the boundary and listed files reference ${writer}`, () => { + expect(references.get(writer)).toEqual(new Set(allowed)) + }) + } +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts new file mode 100644 index 00000000000..8b086c56e39 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import type { TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' +import { closeLeafOrTab } from './terminal-topology-commit' + +// An ssh: partition, so a leaked host id or worktree path would show in the span. +const HOST_ID: ExecutionHostId = 'ssh:target-1' +const WORKTREE_ID = 'ssh-repo::/srv/app' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const SPLIT_TAB = 'tab-split' +const PINNED_TAB = 'tab-pinned' +const CLOSED_TAB = 'tab-closed-earlier' +const NOW = 1_700_000_000_000 + +function tab(id: string, ptyId: string, isPinned = false) { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...(isPinned ? { isPinned } : {}) + } +} + +function session(): WorkspaceSessionState { + return { + activeRepoId: 'ssh-repo', + activeWorktreeId: WORKTREE_ID, + activeTabId: SPLIT_TAB, + tabsByWorktree: { [WORKTREE_ID]: [tab(SPLIT_TAB, 'pty-1'), tab(PINNED_TAB, 'pty-3', true)] }, + terminalLayoutsByTabId: { + [SPLIT_TAB]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1', [LEAF_2]: 'pty-2' } + } + }, + closedTerminalTabTombstonesByTabId: { + [CLOSED_TAB]: { closedAt: NOW - 1000, worktreeId: WORKTREE_ID, reason: 'user' } + }, + terminalTopologyRevisionByRepoId: { 'ssh-repo': 3 } + } +} + +function commitFor( + target: TerminalSurfaceCloseTarget, + overrides: Partial = {} +): TerminalSurfaceCloseCommit { + let current = session() + return { + worktreeId: WORKTREE_ID, + target, + options: {}, + requestedSession: current, + ownerMatches: () => true, + hostId: () => HOST_ID, + getSession: () => current, + setSession: (next) => { + current = next + }, + onClosed: () => {}, + ...overrides + } +} + +describe('persistence.terminal-topology span', () => { + let records: { name: string; attributes: Record; exit: unknown }[] + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + records = [] + setActiveSink({ + push: (record) => { + records.push(JSON.parse(JSON.stringify(record))) + }, + flush: () => {}, + close: () => {} + }) + }) + afterEach(() => { + vi.useRealTimers() + _resetTracerForTests() + }) + + function attributesAfter(commit: TerminalSurfaceCloseCommit): Record { + closeLeafOrTab(commit)() + expect(records).toHaveLength(1) + expect(records[0].name).toBe('persistence.terminal-topology') + return records[0].attributes + } + + it('records a committed pane close without ids', () => { + expect(attributesAfter(commitFor({ kind: 'pane', tabId: SPLIT_TAB, leafId: LEAF_2 }))).toEqual({ + kind: 'persistence', + 'topology.kind': 'close_leaf', + 'topology.outcome': 'committed' + }) + expect(JSON.stringify(records[0])).not.toMatch(/pty-|tab-split|ssh-repo|srv|target-1/) + }) + + it('records a committed tab close', () => { + expect(attributesAfter(commitFor({ kind: 'tab', tabId: SPLIT_TAB }))).toMatchObject({ + 'topology.kind': 'close_tab', + 'topology.outcome': 'committed' + }) + }) + + it('records a refusal with its reason code', () => { + expect(attributesAfter(commitFor({ kind: 'tab', tabId: PINNED_TAB }))).toMatchObject({ + 'topology.kind': 'close_tab', + 'topology.outcome': 'refused', + 'topology.refusal': 'terminal_tab_pinned' + }) + }) + + it('records a close that changes nothing as a noop', () => { + const echo = commitFor({ kind: 'tab', tabId: CLOSED_TAB }, { options: { allowMissing: true } }) + expect(attributesAfter(echo)).toMatchObject({ 'topology.outcome': 'noop' }) + }) + + it('records a thrown commit as a failed span and rethrows', () => { + const mutation = closeLeafOrTab( + commitFor( + { kind: 'tab', tabId: SPLIT_TAB }, + { + getSession: () => { + throw new Error('read failed') + } + } + ) + ) + expect(mutation).toThrow('read failed') + expect(records).toHaveLength(1) + expect(records[0].attributes).toMatchObject({ 'topology.outcome': 'threw' }) + expect(records[0].exit).toMatchObject({ _tag: 'Failure' }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts new file mode 100644 index 00000000000..dee0f6283a9 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -0,0 +1,57 @@ +import { startSpan } from '../../observability/tracer' +import { + terminalSurfaceCloseMutation, + type TerminalSurfaceCloseCommit +} from '../../runtime/terminal-surface-close' +import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' + +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Today it wraps only +// the close, whose transform still lives in runtime/; the other writers move here later. + +/** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ +type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' + +export function closeLeafOrTab( + commit: TerminalSurfaceCloseCommit +): () => DurableProfileStateMutation { + return traced( + commit.target.kind === 'pane' ? 'close_leaf' : 'close_tab', + terminalSurfaceCloseMutation(commit), + // Refusals are fixed reason codes, never ids. + (refusal) => refusal?.message + ) +} + +/** + * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. + * Attributes stay low-cardinality: no pane key, PTY id or path. + */ +function traced( + kind: TerminalTopologyCommitKind, + mutate: () => DurableProfileStateMutation, + refusalOf: (value: T) => string | undefined +): () => DurableProfileStateMutation { + return () => { + const span = startSpan('persistence.terminal-topology', { + attributes: { kind: 'persistence', 'topology.kind': kind } + }) + let result: DurableProfileStateMutation + // Why only mutate(): `threw` must mean the write failed, never that tracing did. + try { + result = mutate() + } catch (error) { + span.setAttribute('topology.outcome', 'threw') + span.fail(error instanceof Error ? error : String(error)) + throw error + } + const refusal = refusalOf(result.value) + if (refusal !== undefined) { + span.setAttribute('topology.outcome', 'refused') + span.setAttribute('topology.refusal', refusal) + } else { + span.setAttribute('topology.outcome', result.persist === false ? 'noop' : 'committed') + } + span.end() + return result + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-membership.ts b/src/main/persistence/terminal-topology/terminal-topology-membership.ts new file mode 100644 index 00000000000..99437157437 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-membership.ts @@ -0,0 +1,56 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' + +export function advanceTerminalTopologyRevision( + session: WorkspaceSessionState, + worktreeId: string +): WorkspaceSessionState { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return { + ...session, + terminalTopologyRevisionByRepoId: { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 + } + } +} + +/** + * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — + * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the + * pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId. + * + * Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the + * layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read — + * the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded + * `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured). + */ +export function findTerminalTabIdForLeaf( + session: WorkspaceSessionState | undefined, + leafId: string +): string | undefined { + const layouts = session?.terminalLayoutsByTabId + if (!layouts) { + return undefined + } + for (const tabId of Object.keys(layouts)) { + if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) { + return tabId + } + } + return undefined +} + +export function hasHostAuthoritativeTerminalMembership( + session: WorkspaceSessionState | undefined, + worktreeId: string +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return ( + (session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 || + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some( + (tombstone) => tombstone.worktreeId === worktreeId + ) + ) +} diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 12b3340d2ac..32af5b46dd5 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -12,7 +12,7 @@ import { Store } from '../persistence/loading-store/store' import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' export const ACK_WORKTREE = 'repo1::/tmp/worktree' export const ACK_TAB = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 179dba29bd1..6e55edb32df 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -7,7 +7,7 @@ import { ACK_WORKTREE, createAcknowledgedTabRetirementFixture } from './acknowledged-terminal-tab-retirement-fixture' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome' const fixtures: ReturnType[] = [] diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index a5d1d899723..9deb2e20fe6 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 80ed0bb726e..54b579a1caf 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/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index f08c9a4a705..25ed04c2f32 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -6,10 +6,8 @@ import { retireLeavesFromTerminalLayout, type RetiredTerminalSurface } from './mobile-session-terminal-retirement' -import { - advanceTerminalTopologyRevision, - rebaseWorkspaceSessionTerminalMembership -} from './workspace-session-terminal-membership-authority' +import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' function visibleTypeForContentType( contentType: string | undefined diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index a4d01045475..35f9e7991a5 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -12,7 +12,6 @@ import { holdAgentSessionInventory } from './structured-agent-session-inventory- import type { Tab } from '../../shared/tab-types' import { resolveTerminalCloseTarget, - terminalSurfaceCloseMutation, type PaneCloseResolution, type RendererTerminalClose, type TerminalSurfaceCloseOptions @@ -25,6 +24,7 @@ import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-re import type { PtyControllerInventory } from './runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' +import { closeLeafOrTab } from '../persistence/terminal-topology/terminal-topology-commit' export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { // Why: headless serve backs browser panes with offscreen WebContents that live @@ -131,7 +131,7 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu let refusal: Error | undefined try { refusal = await store.runDurableMutation( - terminalSurfaceCloseMutation({ + closeLeafOrTab({ worktreeId, target, options, diff --git a/src/main/runtime/orca-runtime-get-runtime-id.ts b/src/main/runtime/orca-runtime-get-runtime-id.ts index ee7fe60e85e..59da5242d64 100644 --- a/src/main/runtime/orca-runtime-get-runtime-id.ts +++ b/src/main/runtime/orca-runtime-get-runtime-id.ts @@ -188,7 +188,7 @@ export class OrcaRuntimeWithGetRuntimeId extends OrcaRuntimeWithHasExactPersiste worktreeId: string, session: WorkspaceSessionState ): void { - this.workspaceSessions.set(worktreeId, session) + this.workspaceSessions.setForWorktree(worktreeId, session) } protected getKnownWorkspaceSessionWorktreeIds(): Set { diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index ddb74df4dc8..6ce5f8a3650 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -15,7 +15,7 @@ import { sshRemotePtyLeaseAllowsReattach } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { RuntimeStore } from './runtime-store-contract' import { SSH_PANE_RECOVERY_GRACE_MS } from './orca-runtime-core' -import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership' export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession { // Why: keep an existing snapshot's browser tabs in sync with the live bridge diff --git a/src/main/runtime/orca-runtime-terminal-close-records.test.ts b/src/main/runtime/orca-runtime-terminal-close-records.test.ts index f05ac78957a..28bb3e62d2e 100644 --- a/src/main/runtime/orca-runtime-terminal-close-records.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-records.test.ts @@ -19,7 +19,7 @@ import { WORKTREE_PATH, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const SSH_REPO_ID = 'ssh-repo' const SSH_HOST_ID = 'ssh:target-1' diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts index c9bd30b7199..98e22b90b56 100644 --- a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -23,7 +23,7 @@ import { retireTerminalSurfaceFromPersistence, sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const splitLayout = { root: { diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index 3c18eb68def..57621cc5113 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -2,7 +2,7 @@ import { OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal } from './orca-runtime-publish-pty-backed-mobile-session-terminal' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parsePaneKey } from '../../shared/stable-pane-id' -import { hasHostAuthoritativeTerminalMembership } from './workspace-session-terminal-membership-authority' +import { hasHostAuthoritativeTerminalMembership } from '../persistence/terminal-topology/terminal-topology-membership' import type { RuntimeMobileSessionTabsSnapshot, RuntimeSyncedLeaf 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 d66ef3d2fa1..20abf16a69c 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 903f8d440af..10b5610e353 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 a09e82464ba..227654cb022 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 4c7fe2103d7..9fe1f2083bb 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/runtime-legacy-worker-terminal-recovery-persistence.test.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts new file mode 100644 index 00000000000..dd93ec5ef39 --- /dev/null +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { withDurableRuntimeStore } from './runtime-durable-store-fixture' +import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' +import type { LegacyWorkerRecoveryResolution } from './runtime-legacy-worker-terminal-recovery-types' +import type { RuntimeStore } from './runtime-store-contract' + +function makeResolution(index: number): LegacyWorkerRecoveryResolution { + return { + resolution: index % 2 === 0 ? 'adopted' : 'exited', + candidate: { + dispatchId: `dispatch-${index}`, + dispatchStatus: 'dispatched', + contractVersion: 1, + taskId: `task-${index}`, + worktreeId: index % 2 === 0 ? `repo::/workspace-${index}` : `folder:workspace-${index}`, + terminalHandle: `handle-${index}`, + paneKey: `tab-${index}:leaf-${index}`, + tabId: `tab-${index}`, + leafId: `leaf-${index}`, + processIncarnation: `pty-${index}:inc-${index}`, + ptyId: `pty-${index}`, + incarnationId: `inc-${index}` + } + } +} + +function makeFixture(hosts: readonly ExecutionHostId[], count: number) { + const resolutions = Array.from({ length: count }, (_, index) => makeResolution(index)) + const sessions = new Map() + const owners = new Map() + for (const [index, { candidate }] of resolutions.entries()) { + const host = hosts[index % hosts.length]! + owners.set(candidate.worktreeId, host) + const session = sessions.get(host) ?? { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: {} + } + session.tabsByWorktree[candidate.worktreeId] = [ + { + id: candidate.tabId, + ptyId: candidate.ptyId, + worktreeId: candidate.worktreeId, + title: 'Worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + session.sleepingAgentSessionsByPaneKey![candidate.paneKey] = { + paneKey: candidate.paneKey, + worktreeId: candidate.worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: `session-${index}` }, + prompt: '', + state: 'waiting', + capturedAt: 1, + updatedAt: 1 + } + sessions.set(host, session) + } + const getWorkspaceSession = (hostId?: string | null) => { + const session = sessions.get(hostId ?? 'local') + if (!session) { + throw new Error('Unexpected host') + } + return session + } + const setWorkspaceSession = vi.fn((session: WorkspaceSessionState, hostId?: string | null) => { + sessions.set(hostId ?? 'local', session) + }) + const flushPendingOrThrowAsync = vi.fn(async () => {}) + const unused = () => { + throw new Error('Unexpected persistence dependency') + } + const store: RuntimeStore = withDurableRuntimeStore({ + getRepos: unused, + getRepo: unused, + addRepo: unused, + updateRepo: unused, + getAllWorktreeMeta: unused, + getWorktreeMeta: unused, + setWorktreeMeta: unused, + removeWorktreeMeta: unused, + getGitHubCache: unused, + getSettings: unused, + getWorkspaceSession, + setWorkspaceSession, + flushPendingOrThrowAsync + }) + const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + unused, + (worktreeId) => owners.get(worktreeId) ?? null + ) + return { + persistence, + resolutions, + getWorkspaceSession, + setWorkspaceSession, + flushPendingOrThrowAsync + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('legacy worker recovery persistence snapshot budget', () => { + it.each([1, 10, 100])('takes two host snapshots for %i recovered workers', async (count) => { + const fixture = makeFixture(['local'], count) + const clone = vi.spyOn(globalThis, 'structuredClone') + + const result = await fixture.persistence.persist(fixture.resolutions) + + expect(result).toEqual( + new Set(fixture.resolutions.map(({ candidate }) => candidate.dispatchId)) + ) + expect(clone).toHaveBeenCalledTimes(2) + expect(fixture.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + expect(fixture.getWorkspaceSession().sleepingAgentSessionsByPaneKey).toEqual({}) + for (const { candidate, resolution } of fixture.resolutions) { + expect(fixture.getWorkspaceSession().tabsByWorktree[candidate.worktreeId]).toHaveLength( + resolution === 'exited' ? 0 : 1 + ) + } + }) + + it('bounds snapshots by host for an interleaved local, SSH and runtime batch', async () => { + const hosts = ['local', 'ssh:remote', 'runtime:peer'] as const + const fixture = makeFixture(hosts, 12) + const clone = vi.spyOn(globalThis, 'structuredClone') + + expect((await fixture.persistence.persist(fixture.resolutions)).size).toBe(12) + + expect(clone).toHaveBeenCalledTimes(hosts.length * 2) + for (const host of hosts) { + expect(fixture.getWorkspaceSession(host).sleepingAgentSessionsByPaneKey).toEqual({}) + } + expect(fixture.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + }) + + it('rolls back every host without overwriting edits made during the durable write', async () => { + const hosts = ['local', 'ssh:remote', 'runtime:peer'] as const + const fixture = makeFixture(hosts, 12) + const originals = new Map( + hosts.map((host) => [host, structuredClone(fixture.getWorkspaceSession(host))]) + ) + const pendingWrite = Promise.withResolvers() + const writeStarted = Promise.withResolvers() + fixture.flushPendingOrThrowAsync.mockImplementationOnce(() => { + writeStarted.resolve() + return pendingWrite.promise + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = fixture.persistence.persist(fixture.resolutions) + await writeStarted.promise + for (const host of hosts) { + const current = fixture.getWorkspaceSession(host) + expect(current.sleepingAgentSessionsByPaneKey).toEqual({}) + // In-place edits must not mutate the final snapshot used by rollback. + current.activeWorktreeId = `new-selection-${host}` + current.tabsByWorktree[`new-folder-${host}`] = [] + } + pendingWrite.reject(new Error('Disk write failed')) + + expect(await result).toEqual(new Set()) + for (const host of hosts) { + const original = originals.get(host)! + expect(fixture.getWorkspaceSession(host)).toEqual({ + ...original, + activeWorktreeId: `new-selection-${host}`, + tabsByWorktree: { ...original.tabsByWorktree, [`new-folder-${host}`]: [] } + }) + } + }) + + it('still flushes previously adopted surfaces when this batch makes no session edits', async () => { + const fixture = makeFixture(['local'], 10) + fixture.getWorkspaceSession().sleepingAgentSessionsByPaneKey = {} + for (const resolution of fixture.resolutions) { + resolution.resolution = 'adopted' + } + + expect((await fixture.persistence.persist(fixture.resolutions)).size).toBe(10) + + expect(fixture.setWorkspaceSession).not.toHaveBeenCalled() + expect(fixture.flushPendingOrThrowAsync).toHaveBeenCalledTimes(1) + }) + + it('does not snapshot or flush an empty or unroutable batch', async () => { + const fixture = makeFixture(['local'], 1) + const clone = vi.spyOn(globalThis, 'structuredClone') + + expect(await fixture.persistence.persist([])).toEqual(new Set()) + expect(await fixture.persistence.persist([makeResolution(100)])).toEqual(new Set()) + + expect(clone).not.toHaveBeenCalled() + expect(fixture.flushPendingOrThrowAsync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index 345e2c08377..489c1024efb 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -66,9 +66,12 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { if (next !== session) { setWorkspaceSession(next, hostId) } - staged.set(hostId, cloneWorkspaceSessionState(getWorkspaceSession(hostId))) dispatchIds.add(candidate.dispatchId) } + // Rollback needs the final stored state, not a full-session copy after every worker. + for (const hostId of originals.keys()) { + staged.set(hostId, cloneWorkspaceSessionState(getWorkspaceSession(hostId))) + } return { value: dispatchIds, persist: dispatchIds.size > 0, diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index 106799cf7e4..e2a4cd1df5d 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -5,7 +5,7 @@ import { collectPersistedTerminalLeafIds } from './mobile-session-layout-project import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology' import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' type Claim = RuntimeTerminalOrphanAdoptionRequest['claims'][number] type Topology = NonNullable diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index e45425b8dd3..f6bb0014169 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -116,7 +116,7 @@ export class RuntimeWorkspaceSessionController { : null } - set(worktreeId: string, session: WorkspaceSessionState): void { + setForWorktree(worktreeId: string, session: WorkspaceSessionState): void { this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId)) } diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts new file mode 100644 index 00000000000..de09e72b7e5 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -0,0 +1,238 @@ +// Codex answers an interrupt it took before it ends the turn: on `TurnAborted` the app-server +// answers pending interrupts, then sends `turn/completed` (interrupted) on the same channel. The +// Stop's settle, which runs between the two, must label the turn the Stop's: no end row for it +// ever reads as a failure. Driven through the shipped host, journal and Codex adapter; only the +// Codex child is fake, and the test delivers the turn's end after the Stop has answered. + +import { mkdtemp, rm } 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 { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + liveTestJournalRows, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { parseJournalRow } from '../native-chat/agent-session-journal/journal-row-schema' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const CALLER = { callerKey: 'codex-interrupt-order-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let interrupts: number +let turns: ReturnType +let opened: Set +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId?: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +/** Every end row written for turn-1, as a row or a lifecycle batch's mutation, in order. */ +function turnEndRows(): AgentJournalTurnItem[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, SESSION).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + return [] + } + const { row } = parsed + const bodies = row.kind === 'lifecycle-batch' ? row.mutations : [row] + return bodies.flatMap((entry) => + entry.kind === 'item' && + entry.body.kind === 'turn' && + entry.body.turnId === 'turn-1' && + entry.body.state !== 'running' + ? [entry.body] + : [] + ) + }) +} + +/** The Stop has answered with Codex's end still to come; then Codex ends the turn. */ +async function expectInterruptedThroughout(): Promise { + expect(interrupts).toBe(1) + expect(turnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + + turns.end('interrupted') + + await vi.waitFor(async () => expect(await owesWork()).toBe(false)) + const ends = turnEndRows() + expect(ends.length).toBeGreaterThan(0) + for (const end of ends) { + expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + } +} + +async function owesWork(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-interrupt-order-')) + answers = 0 + interrupts = 0 + opened = new Set() + turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { + if (method === 'turn/started') { + opened.add(turns.turnId ?? '') + } + handlers?.onNotification?.(method, params) + }) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/interrupt') { + // Taken: answered now; the test sends the turn's end once the Stop has answered. A turn + // Codex runs but whose turn/started was not yet read gets that frame ahead of the answer. + interrupts += 1 + if (turns.turnId !== null && !opened.has(turns.turnId)) { + turns.start() + } + return {} + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () => { + it.each([ + ['names the running turn', 'turn-1'], + ['names no turn', undefined] + ] as const)( + 'reads the turn as interrupted by the Stop, never failed, when it %s', + async (_, named) => { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop(named) + + await expectInterruptedThroughout() + } + ) + + // No turn showed when the person pressed Stop, which interrupts the answered turn at once: the + // turn that opens is bound by the Stop's settle. + it('reads the turn that opens behind an in-flight turn/start as interrupted, never failed', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + await stopping + + await expectInterruptedThroughout() + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 57e632a6c85..b7bbd283fd8 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,7 +1,8 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn // withdraws it, and nothing reads as working after. A send made while that turn runs, a -// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop or a send made -// before Codex opens that turn waits for it to open, since Codex refuses an interrupt until then. +// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop made before Codex +// opens that turn interrupts it at once; refused before Codex's thread runs it, the Stop waits +// for it to open and interrupts once more. A send made then waits for it to open. // Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, // keeping Codex 0.157's turn bookkeeping. @@ -9,10 +10,11 @@ import { mkdtemp, rm } 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 { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { settledWithin } from '../codex/codex-structured-dispatch-test-support' @@ -22,6 +24,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -87,6 +90,13 @@ let answers: number let steers: number let interrupts: number let childCloses: number +let connections: number +/** Closes that fail before one goes through, as a kill that times out. */ +let failingCloses: number +/** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ +let interruptsFail: boolean +/** Codex takes the interrupt and answers it; the test sends that turn's end later. */ +let interruptEndHeld: boolean let turns: ReturnType let operations = 0 @@ -171,6 +181,12 @@ async function settled(): Promise<{ } } +async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) return submission?.dispatchState === 'rejected' @@ -187,6 +203,10 @@ beforeEach(async () => { steers = 0 interrupts = 0 childCloses = 0 + connections = 0 + failingCloses = 0 + interruptsFail = false + interruptEndHeld = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -196,6 +216,7 @@ beforeEach(async () => { connectionHandlers = {} ) => { handlers = connectionHandlers + connections += 1 const connection: CodexAppServerConnection = { pid: 4321, closed: false, @@ -214,6 +235,20 @@ beforeEach(async () => { steers += 1 return turns.routes['turn/steer'](params) } + if (method === 'turn/interrupt' && interruptsFail) { + interrupts += 1 + throw new CodexAppServerRequestError( + 'turn/interrupt', + -32603, + 'codex app-server turn/interrupt failed: could not submit', + 'could not submit' + ) + } + if (method === 'turn/interrupt' && interruptEndHeld) { + interrupts += 1 + turns.takeInterrupt() + return {} + } if (method === 'turn/interrupt') { interrupts += 1 return turns.routes['turn/interrupt'](params) @@ -224,6 +259,10 @@ beforeEach(async () => { respond: () => {}, respondWithError: () => {}, close: async () => { + if (failingCloses > 0) { + failingCloses -= 1 + throw new Error('the kill timed out') + } childCloses += 1 return true } @@ -472,7 +511,7 @@ describe('a second send made after Codex answered the first, before it opened th }) describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { - it('waits for the turn to open, then stops it and withdraws the send', async () => { + it('refused before the thread runs it, waits for the turn to open, then stops it and withdraws the send', async () => { const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) @@ -482,17 +521,16 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() await began.promise - // Without the wait, it would reach Codex now, which would refuse it, and be done. const held = settledWithin(stopping, 1_000) await vi.advanceTimersByTimeAsync(1_000) expect(openWaits.ended).toEqual([]) expect(await held).toBe('held') - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) turns.start() await stopping - expect(interrupts).toBe(1) - expect(turns.turnId).toBeNull() + expect(interrupts).toBe(2) + await vi.waitFor(() => expect(turns.turnId).toBeNull()) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') ) @@ -501,14 +539,8 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur }) describe('a Stop in that window that the turn never opens for', () => { - async function statusRows(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' ? [item.body.text] : [] - ) - } - - async function waitingStop(): Promise<{ stopping: Promise }> { - await send('look around') + async function waitingStop(): Promise<{ stopping: Promise; sent: string }> { + const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) await host.flushStreamedEvents(SESSION) const began = Promise.withResolvers() @@ -519,7 +551,7 @@ describe('a Stop in that window that the turn never opens for', () => { const held = settledWithin(stopping, 200) await vi.advanceTimersByTimeAsync(200) expect(await held).toBe('held') - return { stopping } + return { stopping, sent } } it('says Codex had no turn running when that turn ends first', async () => { @@ -528,12 +560,13 @@ describe('a Stop in that window that the turn never opens for', () => { turns.end('interrupted') await stopping - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) + expect(childCloses).toBe(0) expect(await statusRows()).toContain('Codex had no turn running to stop.') }) it('lets a chat closed behind it close within its bound and one eviction', async () => { - const { stopping } = await waitingStop() + const { stopping, sent } = await waitingStop() const closing = host.close(SESSION, 'evict') const closedWithinBound = settledWithin( @@ -549,8 +582,10 @@ describe('a Stop in that window that the turn never opens for', () => { expect(await closedWithinBound).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) - expect(interrupts).toBe(0) - expect(await statusRows()).toContain('Codex had no turn running to stop.') + expect(interrupts).toBe(1) + // Its wait ran out with the turn still able to open, so the Stop ended the child and took the + // send back. + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') }) it('lets the app quit behind it within the eviction budget, and still close the child', async () => { @@ -570,6 +605,206 @@ describe('a Stop in that window that the turn never opens for', () => { }) }) +describe("a Stop pressed while Codex's turn/start is in flight", () => { + function turnRow( + items: Awaited>['items'] + ) { + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-1') + } + + it("interrupts the turn that opens, which reads as the Stop's, and nothing reads as running", async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + // Queued behind the send's handover on the session's lane, so it runs after Codex answers. + const stopping = stop() + release() + await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) + turns.start() + await stopping + + // Refused before the thread ran the turn, then taken once it opened. + expect(interrupts).toBe(2) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + // Codex's end, after its answer, settles the send. + await vi.waitFor(async () => expect((await settled()).owesWork).toBe(false)) + }) + + it('ends the child when its interrupt was refused and the turn has not opened by the end of its wait', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(interrupts).toBe(1) + expect(childCloses).toBe(1) + expect((await settled()).owesWork).toBe(false) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() + }) + + // Codex records a prompt only once its turn starts, so a send whose turn never opened never ran: + // the Stop takes it back, and no send in doubt holds the client's queue. + async function stoppedBeforeItsTurnOpened(): Promise { + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + return sent + } + + async function nextSendStartsANewChild(): Promise { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const next = await send('try again') + await vi.waitFor(() => expect(answers).toBe(2)) + expect(connections).toBe(2) + return next + } + + it('withdraws the send whose turn never opened, and the next send starts a new child', async () => { + const sent = await stoppedBeforeItsTurnOpened() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + await nextSendStartsANewChild() + }) + + // Nothing it stopped stays in the transcript, so a Stop row would read as stopping the turn before. + it('leaves no Stop row under the turn before once it takes the send back', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'completed' + }) + }) + + // Its turn/start answer lost, the send is in doubt rather than pending; the child's end takes it + // back all the same, so it writes no row either. + it('leaves no Stop row under the turn before when it takes back a send whose answer was lost', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const answer = turns.routes['turn/start'] + turns.routes['turn/start'] = () => { + turns.routes['turn/start'] = answer + throw new Error('codex app-server request timed out: turn/start') + } + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + ) + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + }) + + it('withdraws it too when the child end fails and a later retry lands it', async () => { + failingCloses = 1 + const sent = await stoppedBeforeItsTurnOpened() + expect(childCloses).toBe(0) + + await nextSendStartsANewChild() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + }) + + // A turn that opened may hold the send: the child's end leaves it in doubt, as before. + it('leaves a send in doubt when its turn opened before the child end', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + interruptsFail = true + + await stop() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + // Handed over once the stopped turn read ended, it started its own turn, however late that + // turn's own end then arrives; a second Stop finds its turn never opened. + it('withdraws a send made after a Stop that started its own turn, which never opened', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + interruptEndHeld = true + await stop() + interruptEndHeld = false + const next = await send('run this after the stop') + await vi.waitFor(() => expect(answers).toBe(2)) + // The stopped turn's own end arrives after the next send was handed over. + turns.end('interrupted') + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, next)).toBe('withdrawn') + }) + + // Codex drains a steer into the running turn, so it may hold one it never echoed. + it('leaves a send steered into the open turn in doubt when the child end follows', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const steered = await send('and check the tests') + await vi.waitFor(() => expect(steers).toBe(1)) + interruptsFail = true + + await stop('turn-1') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, steered)).toBe('unknown') + }) + + // Sent after the Stop, to a child that then dies before its turn opens: the Stop, still in force, + // was never this send's. + it('leaves a send made after the Stop in doubt when its child dies', async () => { + await stoppedBeforeItsTurnOpened() + const next = await nextSendStartsANewChild() + + handlers?.onExit?.(new Error('codex app-server exited')) + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, next)).not.toBe('pending') + ) + expect(verdictOf((await settled()).submissions, next)).toBe('unknown') + }) +}) + describe('a cold send with no Stop behind it', () => { /** Well inside one eviction step's budget, and far inside the bound a Stop waits. */ const PROMPTLY_MS = 1_000 @@ -587,6 +822,17 @@ describe('a cold send with no Stop behind it', () => { expect(childCloses).toBe(1) }) + // Only a person's Stop takes a send back: a host's close leaves it in doubt, as before. + it('leaves its send in doubt when the host closes the chat', async () => { + await answeredColdSend() + const sent = (await settled()).submissions[0]?.clientMessageId ?? '' + + await host.close(SESSION, 'evict') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + }) + it('never delays quitting the app', async () => { await answeredColdSend() 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 9a8d9edb96f..14efe114f90 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 b4d0399d10d..9926aaba2dc 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 ac6df4c4b2b..e0bfa5bbdd3 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/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts index 0c3f996153f..279955fde1f 100644 --- a/src/main/runtime/terminal-intentional-stop-exit.test.ts +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -29,7 +29,7 @@ import { WORKTREE_PATH, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' diff --git a/src/main/runtime/terminal-leaf-tab-resolution.test.ts b/src/main/runtime/terminal-leaf-tab-resolution.test.ts index 2fe8a6d0344..8f57c05f59c 100644 --- a/src/main/runtime/terminal-leaf-tab-resolution.test.ts +++ b/src/main/runtime/terminal-leaf-tab-resolution.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership' function layout(...leafIds: string[]): TerminalLayoutSnapshot { let root = { type: 'leaf' as const, leafId: leafIds[0] } diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 0feee12402c..636834a6178 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -14,7 +14,7 @@ import { type WorkspaceSessionTerminalTabCloseResult } from '../../shared/workspace-session-terminal-tab-close' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import type { ExecutionHostId } from '../../shared/execution-host' diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index 7798ae7831c..4c2ea719da5 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -5,7 +5,6 @@ import type { } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization' import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { @@ -148,59 +147,6 @@ function rebaseIncarnationBindings( return Object.keys(retained).length > 0 ? retained : undefined } -export function advanceTerminalTopologyRevision( - session: WorkspaceSessionState, - worktreeId: string -): WorkspaceSessionState { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return { - ...session, - terminalTopologyRevisionByRepoId: { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 - } - } -} - -/** - * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — - * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the - * pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId. - * - * Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the - * layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read — - * the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded - * `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured). - */ -export function findTerminalTabIdForLeaf( - session: WorkspaceSessionState | undefined, - leafId: string -): string | undefined { - const layouts = session?.terminalLayoutsByTabId - if (!layouts) { - return undefined - } - for (const tabId of Object.keys(layouts)) { - if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) { - return tabId - } - } - return undefined -} - -export function hasHostAuthoritativeTerminalMembership( - session: WorkspaceSessionState | undefined, - worktreeId: string -): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return ( - (session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 || - Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some( - (tombstone) => tombstone.worktreeId === worktreeId - ) - ) -} - export function rebaseWorkspaceSessionTerminalMembership( incoming: WorkspaceSessionState, prior: WorkspaceSessionState | undefined diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 889f792abd4..d4c795817a3 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -183,7 +183,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () ) const { findTerminalTabIdForLeaf } = - await import('./runtime/workspace-session-terminal-membership-authority') + await import('./persistence/terminal-topology/terminal-topology-membership') // The relay resolves the tab from the live layout before binding, exactly as the production // path does; forwarding the lease's frozen `TAB` here is what would strand the pane. const resolvedTabId = findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2cd1f394cd7..63516856036 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -94,7 +94,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { findTerminalTabIdForLeaf, hasHostAuthoritativeTerminalMembership -} from '../runtime/workspace-session-terminal-membership-authority' +} from '../persistence/terminal-topology/terminal-topology-membership' import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' import { diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index 052fcdf2edc..749c6a23f22 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -34,8 +34,6 @@ export type CodexAccountsApi = { ptyId: string launchAccountId: string | null activeAccountId: string | null - /** Optional for compatibility with a pre-reason main process. */ - reason?: 'account-change' | 'home-route-change' }[] > /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts index d5ca98cdf76..e94b5f35b87 100644 --- a/src/preload/api/codex-accounts-bridge.ts +++ b/src/preload/api/codex-accounts-bridge.ts @@ -30,7 +30,6 @@ export const codexAccountsApi = { ptyId: string launchAccountId: string | null activeAccountId: string | null - reason?: 'account-change' | 'home-route-change' }[] > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index cc90244579e..e4109551bf3 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button' import { useAppStore } from '../store' import { translate } from '@/i18n/i18n' import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus' -import { buildCodexRestartNoticeKey } from './codex-restart-notice-key' import { awaitsCodexRestartAnswer } from './codex-restart-notice-state' function isInsideHiddenTree(element: HTMLElement): boolean { @@ -14,7 +13,6 @@ function isInsideHiddenTree(element: HTMLElement): boolean { type RestartNotice = { previousAccountLabel: string nextAccountLabel: string - homeRouteChanged?: true } export default function CodexRestartChip({ @@ -53,7 +51,7 @@ export default function CodexRestartChip({ return ( void @@ -98,7 +96,13 @@ function LoudRestartOverlay({ if (shouldFocusMobileDriverAction(document.activeElement, document.body, paneScope)) { root.focus() } - }, [isVisible, noticeKey, shouldFocus]) + }, [ + isVisible, + ptyId, + restartNotice.previousAccountLabel, + restartNotice.nextAccountLabel, + shouldFocus + ]) return (
- {restartNotice.homeRouteChanged - ? translate('auto.components.CodexRestartChip.8f0d5c92a1', 'Codex setup changed') - : translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')} + {translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')}
- {restartNotice.homeRouteChanged - ? translate( - 'auto.components.CodexRestartChip.3ea91b5c07', - 'This Codex session is using an outdated configuration' - ) - : translate( - 'auto.components.CodexRestartChip.a4c8e1b2f7', - 'Codex is still signed in as {{value0}}', - { value0: restartNotice.previousAccountLabel } - )} + {translate( + 'auto.components.CodexRestartChip.a4c8e1b2f7', + 'Codex is still signed in as {{value0}}', + { value0: restartNotice.previousAccountLabel } + )}
- {restartNotice.homeRouteChanged - ? translate( - 'auto.components.CodexRestartChip.e6b7139d2a', - 'Restart this session to load your current Codex configuration.' - ) - : translate( - 'auto.components.CodexRestartChip.9375620cc3', - 'Restart this session to use {{value0}}. It stays on the previous account until you do.', - { value0: restartNotice.nextAccountLabel } - )} + {translate( + 'auto.components.CodexRestartChip.9375620cc3', + 'Restart this session to use {{value0}}. It stays on the previous account until you do.', + { value0: restartNotice.nextAccountLabel } + )}
- - {/* 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/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index e5bfb32be4f..7d10b767f94 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -486,7 +486,148 @@ describe('StructuredAgentSessionAttentionBridge', () => { expect(store.getState().unreadTerminalTabs[tab.id]).toBe('agent-completion') }) - it('keeps a local subscription and delivers without a source when tab ownership is unresolved', async () => { + it.each<{ selected: string; activeAfterCollision: Partial }>([ + { selected: 'the paired workspace', activeAfterCollision: {} }, + { + selected: 'another workspace', + activeAfterCollision: { + activeWorktreeId: 'repo1::/tmp/other', + activeWorkspaceExecutionHostId: 'local' + } + } + ])( + 'keeps a published paired chat subscribed and delivers after a workspace-id collision with $selected selected', + async ({ activeAfterCollision }) => { + const store = mocks.store + if (!store) { + throw new Error('test store was not initialized') + } + const remoteWorktree = makeWorktree({ + id: WORKSPACE, + repoId: 'repo1', + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + }) + store.setState({ + activeWorktreeId: WORKSPACE, + activeWorkspaceExecutionHostId: 'runtime:env-1', + worktreesByRepo: { repo1: [remoteWorktree] }, + unifiedTabsByWorktree: {}, + runtimeEnvironments: [ + { + id: 'env-1', + name: 'Paired server', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'endpoint' + } + ] + }) + store.setState( + applyWebSessionTabsSnapshot( + store.getState(), + { + worktree: WORKSPACE, + publicationEpoch: 'remote-epoch', + snapshotVersion: 1, + activeGroupId: GROUP, + activeTabId: 'agent-session:session-1', + activeTabType: 'agent-session', + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-1', + title: 'Remote chat', + sessionId: SESSION, + agent: 'codex', + isActive: true + } + ] + }, + 'env-1', + 100 + ) + ) + const tab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] + if (!tab) { + throw new Error('snapshot did not publish a chat tab') + } + expect(tab.contentType).toBe('agent-session') + // The snapshot stamps each mirrored chat with its host, so a later id collision cannot reassign it. + expect(tab.executionHostId).toBe('runtime:env-1') + render() + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) + expect(mocks.subscribeCompletions.mock.calls[0]?.[0]).toEqual({ + kind: 'environment', + environmentId: 'env-1' + }) + + await act(async () => + store.setState({ + ...activeAfterCollision, + worktreesByRepo: { + repo1: [ + remoteWorktree, + makeWorktree({ id: WORKSPACE, repoId: 'repo1', hostId: 'local' }) + ] + }, + groupsByWorktree: { + [WORKSPACE]: store + .getState() + .groupsByWorktree[WORKSPACE].map((group) => ({ ...group, activeTabId: 'other-tab' })) + } + }) + ) + expect(resolveNotificationTabOwner(store.getState(), tab)).toEqual({ + executionHostId: 'runtime:env-1', + runtimeEnvironmentId: 'env-1' + }) + expect(mocks.unsubscribe).not.toHaveBeenCalled() + expect(mocks.subscribeCompletions).toHaveBeenCalledOnce() + + act(() => hostStream()(completionFrame())) + const paneKey = structuredAgentSessionPaneKey(tab.id, SESSION) + expect(onlyDispatch()).toMatchObject({ notificationSourceId: 'runtime:env-1', paneKey }) + expect(store.getState().unreadAgentCompletionPanes[paneKey]).toBe('agent-completion') + expect(store.getState().unreadTerminalTabs[tab.id]).toBe('agent-completion') + } + ) + + it('keeps a local chat on this machine when a paired workspace with its id is selected', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { [WORKSPACE]: [chatTab({ executionHostId: 'local' })] } + }) + render() + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) + expect(mocks.subscribeCompletions.mock.calls[0]?.[0]).toEqual({ kind: 'local' }) + + await act(async () => + mocks.store?.setState({ + activeWorktreeId: WORKSPACE, + activeWorkspaceExecutionHostId: 'runtime:env-1', + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WORKSPACE, repoId: 'repo1', hostId: 'local' }), + makeWorktree({ + id: WORKSPACE, + repoId: 'repo1', + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + }) + ] + } + }) + ) + expect(mocks.unsubscribe).not.toHaveBeenCalled() + expect(mocks.subscribeCompletions).toHaveBeenCalledOnce() + }) + + // An unrecorded tab follows the chat pane's rule: an id two hosts share names no owner until + // the tab's first snapshot records its host. + it('pauses an unrecorded chat while its workspace id names two hosts, until its host is recorded', async () => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) expect(mocks.subscribeCompletions.mock.calls[0]?.[0]).toEqual({ kind: 'local' }) @@ -500,10 +641,17 @@ describe('StructuredAgentSessionAttentionBridge', () => { } }) ) - expect(mocks.unsubscribe).not.toHaveBeenCalled() - expect(mocks.subscribeCompletions).toHaveBeenCalledOnce() - act(() => hostStream()(completionFrame())) - expect(onlyDispatch().notificationSourceId).toBeUndefined() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + + await act(async () => + mocks.store?.setState({ + unifiedTabsByWorktree: { [WORKSPACE]: [chatTab({ executionHostId: 'local' })] } + }) + ) + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledTimes(2)) + expect(mocks.subscribeCompletions.mock.calls[1]?.[0]).toEqual({ kind: 'local' }) + act(() => hostStream(1)(completionFrame())) + expect(onlyDispatch().notificationSourceId).toBe('local') expect(indicators().paneDot).toBe('agent-completion') }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx index 7c18ea6bfdd..29e469935a5 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx @@ -1,8 +1,11 @@ import { useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + structuredAgentSessionOwnerForTab, + structuredAgentSessionTargetForHost +} from '@/runtime/structured-agent-session-owner' import { getStructuredAgentSessionTurnCompletionFeed } from '@/runtime/structured-agent-session-turn-completion-feed' import { dispatchStructuredTurnCompletionAttention } from './structured-attention-dispatch' import { getStructuredAgentSessionTabs, type StructuredTab } from './structured-agent-session-tabs' @@ -15,14 +18,24 @@ import { getStructuredAgentSessionTabs, type StructuredTab } from './structured- * the tab IS the attention surface. A completion for a session with no open tab has no surface to * mark and no liveness evidence in this process, and the surface adapter would reject it anyway. */ -function StructuredAgentSessionAttention({ tab }: { tab: StructuredTab }): null { - const environmentId = useAppStore((state) => - getRuntimeEnvironmentIdForWorktree(state, tab.worktreeId) - ) - const target = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), - [environmentId] - ) +function StructuredAgentSessionAttention({ + tab +}: { + tab: StructuredTab +}): React.JSX.Element | null { + // The same owner the chat pane and status bridge read, so all of them follow one host. + const owner = useAppStore((state) => structuredAgentSessionOwnerForTab(state, tab)) + const target = useMemo(() => structuredAgentSessionTargetForHost(owner), [owner]) + return target ? : null +} + +function StructuredAgentSessionOwnedAttention({ + tab, + target +}: { + tab: StructuredTab + target: RuntimeClientTarget +}): null { const feed = useMemo(() => getStructuredAgentSessionTurnCompletionFeed(target), [target]) useEffect(() => feed.activate(), [feed]) useEffect( 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 462c63182b3..706777fe645 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 89d8de5d011..98051945865 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 eaee915ac68..3424b39dc3c 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 ebfd23060bb..bef56bf7f81 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 0f126fe0559..6d73bc3ce05 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 cc4ab94202a..3cc1ed29e59 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 fbbfa224333..a39b8698bf6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1322,11 +1322,7 @@ "9263e75f49": "Codex is using the previous account", "d3e8a1f4b2": "Account switched", "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account", - "8f0d5c92a1": "Codex setup changed", - "3ea91b5c07": "This Codex session is using an outdated configuration", - "e6b7139d2a": "Restart this session to load your current Codex configuration.", - "7b1d20f4c8": "Keep current session" + "6133594b12": "Keep old account" }, "FirstLaunchBanner": { "b9e1b966c7": "Dismiss notice", @@ -17814,6 +17810,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.", @@ -18133,6 +18131,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 bf23c6e47a5..60c3f7e592e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1155,11 +1155,7 @@ "a4c8e1b2f7": "Codex sigue conectado como {{value0}}", "d3e8a1f4b2": "Cuenta cambiada", "9375620cc3": "Reinicia esta sesión para usar {{value0}}. Se mantiene en la cuenta anterior hasta que lo hagas.", - "6133594b12": "Mantener cuenta anterior", - "8f0d5c92a1": "La configuración de Codex cambió", - "3ea91b5c07": "Esta sesión de Codex usa una configuración obsoleta", - "e6b7139d2a": "Reinicia esta sesión para cargar tu configuración actual de Codex.", - "7b1d20f4c8": "Mantener sesión actual" + "6133594b12": "Mantener cuenta anterior" }, "FirstLaunchBanner": { "b9e1b966c7": "Descartar aviso", @@ -17592,6 +17588,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.", @@ -17816,6 +17814,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 67a65787476..7348c11016f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1258,11 +1258,7 @@ "9263e75f49": "Codex utilise le compte précédent", "d3e8a1f4b2": "Compte changé", "9375620cc3": "Redémarrez cette session pour utiliser {{value0}}. Elle reste sur le compte précédent tant que vous ne l'avez pas fait.", - "6133594b12": "Conserver l'ancien compte", - "8f0d5c92a1": "Configuration Codex modifiée", - "3ea91b5c07": "Cette session Codex utilise une configuration obsolète", - "e6b7139d2a": "Redémarrez cette session pour charger votre configuration Codex actuelle.", - "7b1d20f4c8": "Conserver la session actuelle" + "6133594b12": "Conserver l'ancien compte" }, "FirstLaunchBanner": { "b9e1b966c7": "Masquer l'avis", @@ -17691,6 +17687,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.", @@ -17915,6 +17913,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 68cc4a2b48b..fc5275f5244 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1155,11 +1155,7 @@ "a4c8e1b2f7": "Codex はまだ {{value0}} としてサインインしています", "d3e8a1f4b2": "アカウントを切り替えました", "9375620cc3": "このセッションを再起動して {{value0}} を使用してください。それまでは前のアカウントのままです。", - "6133594b12": "以前のアカウントを保持", - "8f0d5c92a1": "Codex の設定が変更されました", - "3ea91b5c07": "この Codex セッションは古い構成を使用しています", - "e6b7139d2a": "このセッションを再起動して、現在の Codex 構成をロードします。", - "7b1d20f4c8": "現在のセッションを維持する" + "6133594b12": "以前のアカウントを保持" }, "FirstLaunchBanner": { "b9e1b966c7": "通知を閉じる", @@ -17627,6 +17623,8 @@ "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", + "chatSavedByNewerOrca": "このチャットは新しいバージョンの Orca で保存されました。", + "updateOrcaToOpenChat": "開くには Orca を更新してください。", "unsupported": "このチャットを実行しているコンピューターに新しいバージョンの Orca が必要です。そのコンピューターで Orca を更新してから、もう一度お試しください。", "notAvailable": "このチャットでは利用できません。", "unreachable": "Orca は Agent に接続できませんでした。", @@ -17851,6 +17849,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 aef93a809de..5cc0afd4e43 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1155,11 +1155,7 @@ "a4c8e1b2f7": "Codex가 아직 {{value0}} 계정으로 로그인되어 있습니다", "d3e8a1f4b2": "계정 전환됨", "9375620cc3": "{{value0}}을(를) 사용하려면 이 세션을 다시 시작하세요. 다시 시작하기 전까지는 이전 계정이 유지됩니다.", - "6133594b12": "이전 계정 유지", - "8f0d5c92a1": "Codex 설정이 변경되었습니다.", - "3ea91b5c07": "이 Codex 세션은 오래된 구성을 사용하고 있습니다.", - "e6b7139d2a": "현재 Codex 구성을 로드하려면 이 세션을 다시 시작하세요.", - "7b1d20f4c8": "현재 세션 유지" + "6133594b12": "이전 계정 유지" }, "FirstLaunchBanner": { "b9e1b966c7": "알림 닫기", @@ -17627,6 +17623,8 @@ "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", + "chatSavedByNewerOrca": "이 채팅은 최신 버전의 Orca에서 저장되었습니다.", + "updateOrcaToOpenChat": "열려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 컴퓨터에 최신 버전의 Orca가 필요합니다. 해당 컴퓨터에서 Orca를 업데이트한 후 다시 시도하세요.", "notAvailable": "이 채팅에서는 사용할 수 없습니다.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", @@ -17851,6 +17849,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 050d88c49f8..a7ab666099d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1155,11 +1155,7 @@ "a4c8e1b2f7": "Codex 仍以 {{value0}} 身份登录", "d3e8a1f4b2": "账户已切换", "9375620cc3": "重启此会话以使用 {{value0}}。在重启之前,它将继续使用旧账户。", - "6133594b12": "保留旧账户", - "8f0d5c92a1": "Codex 设置已更改", - "3ea91b5c07": "此 Codex 会话使用过时的配置", - "e6b7139d2a": "重新启动此会话以加载当前 Codex 配置。", - "7b1d20f4c8": "保持当前会话" + "6133594b12": "保留旧账户" }, "FirstLaunchBanner": { "b9e1b966c7": "关闭通知", @@ -17592,6 +17588,8 @@ "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", + "chatSavedByNewerOrca": "此聊天由更新版本的 Orca 保存。", + "updateOrcaToOpenChat": "请更新 Orca 以打开它。", "unsupported": "运行此聊天的计算机需要更新版本的 Orca。请在该计算机上更新 Orca 后重试。", "notAvailable": "此聊天中无法使用此功能。", "unreachable": "Orca 无法连接到智能体。", @@ -17816,6 +17814,7 @@ "sending": "正在发送…" }, "approval": { + "needsNewerOrca": "此请求需要更新版本的 Orca。", "title": "允许 {{value0}}?", "allow": "允许", "deny": "拒绝", diff --git a/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts b/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts new file mode 100644 index 00000000000..74c15995e43 --- /dev/null +++ b/src/renderer/src/lib/codex-session-restart-account-recheck.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { markLiveCodexSessionsForRestart } from './codex-session-restart' + +const ACCOUNT_A = 'account-a@example.com' +const ACCOUNT_B = 'account-b@example.com' +const ACCOUNT_C = 'account-c@example.com' + +/** Main's per-PTY record decides a recorded pane's notice, and the switch decides it when main cannot. */ +describe('Codex account switch recheck against main', () => { + const listStalePanes = vi.fn() + + beforeEach(() => { + listStalePanes.mockReset().mockResolvedValue([]) + useAppStore.setState({ + settings: null, + tabsByWorktree: { + wt1: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt1', + title: 'orca-1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {} + }) + vi.stubGlobal('window', { + api: { + pty: { + inspectProcess: vi + .fn() + .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: false }), + confirmForegroundProcess: vi.fn().mockResolvedValue(null) + }, + codexAccounts: { + listRecordedPaneLanes: vi.fn().mockResolvedValue({ 'pty-1': 'host' }), + listStalePanes + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('clears an open notice and its queued restart once main reports the pane current', async () => { + useAppStore.getState().markCodexRestartNotices([ + { + ptyId: 'pty-1', + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + } + ]) + useAppStore.getState().queueCodexPaneRestarts(['pty-1']) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_B, + nextAccountLabel: ACCOUNT_C, + previousAccountId: 'account-b', + nextAccountId: 'account-c' + }) + + expect(listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] }) + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({}) + }) + + it('falls back to the switch it was given when main cannot answer', async () => { + listStalePanes.mockRejectedValue(new Error('ipc failed')) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + }) +}) diff --git a/src/renderer/src/lib/codex-session-restart-route-recheck.test.ts b/src/renderer/src/lib/codex-session-restart-route-recheck.test.ts deleted file mode 100644 index 81891038754..00000000000 --- a/src/renderer/src/lib/codex-session-restart-route-recheck.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import { markLiveCodexSessionsForRestart } from './codex-session-restart' - -const ACCOUNT_A = 'account-a@example.com' -const ACCOUNT_B = 'account-b@example.com' -const SYSTEM_DEFAULT = 'System default' -const originalWindow = (globalThis as { window?: typeof window }).window - -function seedRouteNotice(args: { - previousAccountId: string | null - previousAccountLabel: string -}): void { - useAppStore.getState().markCodexRestartNotices([ - { - ptyId: 'pty-1', - previousAccountLabel: args.previousAccountLabel, - nextAccountLabel: SYSTEM_DEFAULT, - previousAccountId: args.previousAccountId, - nextAccountId: null, - homeRouteChanged: true - } - ]) -} - -describe('Codex restored route notice recheck', () => { - beforeEach(() => { - useAppStore.setState({ - settings: null as never, - tabsByWorktree: { - wt1: [ - { - id: 'tab-1', - ptyId: 'pty-1', - worktreeId: 'wt1', - title: 'orca-1', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] }, - pendingCodexPaneRestartIds: {}, - codexRestartNoticeByPtyId: {} - }) - ;(globalThis as { window: typeof window }).window = { - ...originalWindow, - api: { - ...originalWindow?.api, - pty: { - ...originalWindow?.api?.pty, - inspectProcess: vi - .fn() - .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: false }), - confirmForegroundProcess: vi.fn().mockResolvedValue(null) - }, - codexAccounts: { - ...originalWindow?.api?.codexAccounts, - listRecordedPaneLanes: vi.fn().mockResolvedValue({ 'pty-1': 'host' }), - listStalePanes: vi.fn().mockResolvedValue([]) - } - } - } as unknown as typeof window - }) - - afterEach(() => { - if (originalWindow) { - ;(globalThis as { window: typeof window }).window = originalWindow - } else { - delete (globalThis as { window?: typeof window }).window - } - }) - - it('clears the notice when main confirms the launch account and route are restored', async () => { - seedRouteNotice({ previousAccountId: 'account-a', previousAccountLabel: ACCOUNT_A }) - useAppStore.getState().queueCodexPaneRestarts(['pty-1']) - - await markLiveCodexSessionsForRestart({ - previousAccountLabel: SYSTEM_DEFAULT, - nextAccountLabel: ACCOUNT_A, - previousAccountId: null, - nextAccountId: 'account-a' - }) - - expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] }) - expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) - expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({}) - }) - - it('keeps the notice while shared-home still differs from real-home', async () => { - seedRouteNotice({ previousAccountId: null, previousAccountLabel: SYSTEM_DEFAULT }) - vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: null, - reason: 'home-route-change' - } - ]) - - await markLiveCodexSessionsForRestart({ - previousAccountLabel: ACCOUNT_B, - nextAccountLabel: SYSTEM_DEFAULT, - previousAccountId: 'account-b', - nextAccountId: null - }) - - expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ - previousAccountLabel: SYSTEM_DEFAULT, - nextAccountLabel: SYSTEM_DEFAULT, - previousAccountId: null, - nextAccountId: null, - homeRouteChanged: true - }) - }) - - it('downgrades the notice when only the launch account remains stale', async () => { - seedRouteNotice({ previousAccountId: 'account-a', previousAccountLabel: ACCOUNT_A }) - vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ - { - ptyId: 'pty-1', - launchAccountId: 'account-a', - activeAccountId: 'account-b', - reason: 'account-change' - } - ]) - - await markLiveCodexSessionsForRestart({ - previousAccountLabel: SYSTEM_DEFAULT, - nextAccountLabel: ACCOUNT_B, - previousAccountId: null, - nextAccountId: 'account-b' - }) - - expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ - previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B, - previousAccountId: 'account-a', - nextAccountId: 'account-b' - }) - }) - - it('preserves the notice when the authoritative recheck fails', async () => { - seedRouteNotice({ previousAccountId: 'account-a', previousAccountLabel: ACCOUNT_A }) - vi.mocked(window.api.codexAccounts.listStalePanes).mockRejectedValue(new Error('ipc failed')) - - await markLiveCodexSessionsForRestart({ - previousAccountLabel: SYSTEM_DEFAULT, - nextAccountLabel: ACCOUNT_A, - previousAccountId: null, - nextAccountId: 'account-a' - }) - - expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ - previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_A, - previousAccountId: 'account-a', - nextAccountId: 'account-a', - homeRouteChanged: true - }) - }) -}) diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index af418074dbe..d858f314058 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -683,8 +683,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { { ptyId: 'pty-1', launchAccountId: 'account-a', - activeAccountId: 'account-b', - reason: 'account-change' + activeAccountId: 'account-b' } ]) @@ -734,8 +733,7 @@ describe('markLiveCodexSessionsForRestart lane scoping', () => { { ptyId: 'pty-stale', launchAccountId: 'account-a', - activeAccountId: 'account-b', - reason: 'account-change' + activeAccountId: 'account-b' } ]) @@ -897,27 +895,6 @@ describe('markRestoredStaleCodexSessionsForRestart', () => { }) }) - it('keeps a system-default home-route change as a restart notice', async () => { - vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: null, - reason: 'home-route-change' - } - ]) - - await markRestoredStaleCodexSessionsForRestart() - - expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ - previousAccountLabel: 'System default', - nextAccountLabel: 'System default', - previousAccountId: null, - nextAccountId: null, - homeRouteChanged: true - }) - }) - it('labels the system default when a pane launched without a managed account', async () => { vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ { ptyId: 'pty-1', launchAccountId: null, activeAccountId: 'account-b' } diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index b2b4e07a22b..7fff41a088e 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -255,8 +255,7 @@ export async function markLiveCodexSessionsForRestart(args: { previousAccountLabel: args.previousAccountLabel, nextAccountLabel: args.nextAccountLabel, previousAccountId: stalePane.launchAccountId, - nextAccountId: stalePane.activeAccountId, - homeRouteChanged: stalePane.reason === 'home-route-change' + nextAccountId: stalePane.activeAccountId } ] } @@ -319,8 +318,7 @@ export async function markRestoredStaleCodexSessionsForRestart(args?: { // Why the ids: main decided staleness by id, and the labels can collide. // Passing only labels hands the store a question it cannot answer. previousAccountId: pane.launchAccountId, - nextAccountId: pane.activeAccountId, - ...(pane.reason === 'home-route-change' ? { homeRouteChanged: true as const } : {}) + nextAccountId: pane.activeAccountId })) ) // Why not every stale pane: the bind sweep suppresses a "notified" pane for the diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 2d931414b18..3b1b928ecbd 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -20,6 +20,7 @@ import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' import { readRuntimeJiraPayload } from './runtime-jira-payload-stream' import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target' +import { parseJiraConnectionStatus } from './runtime-jira-connection-status' export { jiraLookupIssueSummary, jiraReadStatus } from './runtime-jira-summary-client' export { @@ -53,9 +54,11 @@ async function readRemoteJiraPayload( export async function jiraStatus(settings: RuntimeJiraSettings): Promise { const target = getJiraRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'jira.status', undefined, { timeoutMs: 15_000 }) - : window.api.jira.status() + return parseJiraConnectionStatus( + target.kind === 'environment' + ? await callRuntimeRpc(target, 'jira.status', undefined, { timeoutMs: 15_000 }) + : await window.api.jira.status() + ) } export async function jiraConnect( @@ -87,14 +90,11 @@ export async function jiraSelectSite( siteId: JiraSiteSelection ): Promise { const target = getJiraRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'jira.selectSite', - { siteId }, - { timeoutMs: 15_000 } - ) - : window.api.jira.selectSite({ siteId }) + return parseJiraConnectionStatus( + target.kind === 'environment' + ? await callRuntimeRpc(target, 'jira.selectSite', { siteId }, { timeoutMs: 15_000 }) + : await window.api.jira.selectSite({ siteId }) + ) } export async function jiraTestConnection( diff --git a/src/renderer/src/runtime/runtime-jira-connection-status.test.ts b/src/renderer/src/runtime/runtime-jira-connection-status.test.ts new file mode 100644 index 00000000000..29f92e924dc --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-connection-status.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { parseJiraConnectionStatus } from './runtime-jira-connection-status' + +describe('parseJiraConnectionStatus', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['an array', []], + ['a non-boolean connected', { connected: 'yes', viewer: null }], + ['a non-object viewer', { connected: true, viewer: 'me' }], + ['non-array sites', { connected: true, viewer: null, sites: {} }] + ])('reads %s as disconnected', (_label, value) => { + expect(parseJiraConnectionStatus(value)).toEqual({ connected: false, viewer: null }) + }) + + it('keeps a well-formed status', () => { + const status = { + connected: true, + viewer: { accountId: 'a', email: 'a@example.com', displayName: 'A' }, + sites: [], + activeSiteId: 'site-1', + credentialProtection: 'plaintext' + } + expect(parseJiraConnectionStatus(status)).toEqual(status) + }) + + it('fills a missing viewer with null', () => { + expect(parseJiraConnectionStatus({ connected: false })).toEqual({ + connected: false, + viewer: null + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-jira-connection-status.ts b/src/renderer/src/runtime/runtime-jira-connection-status.ts new file mode 100644 index 00000000000..cb1d5a72637 --- /dev/null +++ b/src/renderer/src/runtime/runtime-jira-connection-status.ts @@ -0,0 +1,20 @@ +import type { JiraConnectionStatus } from '../../../shared/jira-types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Why: the paired web client has no Jira preload, so its fallback proxy resolves +// undefined; any reply the store's status comparisons can't read is disconnected. +export function parseJiraConnectionStatus(value: unknown): JiraConnectionStatus { + if ( + !isRecord(value) || + typeof value.connected !== 'boolean' || + !(value.viewer === undefined || value.viewer === null || isRecord(value.viewer)) || + !(value.sites === undefined || Array.isArray(value.sites)) + ) { + return { connected: false, viewer: null } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `connected`, `viewer` and `sites`, the fields status readers dereference, are checked above. + return { ...value, viewer: value.viewer ?? null } as JiraConnectionStatus +} diff --git a/src/renderer/src/runtime/runtime-jira-summary-client.ts b/src/renderer/src/runtime/runtime-jira-summary-client.ts index 3276a5765dc..6f9582b72d3 100644 --- a/src/renderer/src/runtime/runtime-jira-summary-client.ts +++ b/src/renderer/src/runtime/runtime-jira-summary-client.ts @@ -2,14 +2,15 @@ import type { JiraConnectionStatus, JiraIssue } from '../../../shared/jira-types import { createBrowserUuid } from '@/lib/browser-uuid' import { callRuntimeRpc } from './runtime-rpc-client' import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target' +import { parseJiraConnectionStatus } from './runtime-jira-connection-status' export async function jiraReadStatus(settings: RuntimeJiraSettings): Promise { const target = getJiraRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'jira.readStatus', undefined, { - timeoutMs: 15_000 - }) - : window.api.jira.readStatus() + return parseJiraConnectionStatus( + target.kind === 'environment' + ? await callRuntimeRpc(target, 'jira.readStatus', undefined, { timeoutMs: 15_000 }) + : await window.api.jira.readStatus() + ) } export async function jiraLookupIssueSummary( diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts index 5e36883dfe5..97a9f3f29ca 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts @@ -68,6 +68,14 @@ describe('buildMirroredAgentTabs', () => { expect(tab.customLabel).toBeNull() }) + it('records the publishing host on a tab restored from before tabs carried one', () => { + const snapshot = snapshotWith('codex', 'Codex Chat') + const { executionHostId: _recorded, ...unrecorded } = build(snapshot) + const mirrored = build(snapshot, [unrecorded]) + expect(mirrored.id).toBe(unrecorded.id) + expect(mirrored.executionHostId).toBe('runtime:env-1') + }) + it('keeps the provisional tab group when the host publishes a different group', () => { const snapshot = snapshotWith('codex', 'Codex Chat') const provisional = build(snapshot) diff --git a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts index 5c72a20b2aa..7f9042db583 100644 --- a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts +++ b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts @@ -186,27 +186,6 @@ describe('codex restart notices with colliding account labels', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() }) - it('does not collapse an equal-account home-route change', () => { - useAppStore.getState().markCodexRestartNotices([ - { - ptyId: 'pty-1', - previousAccountLabel: 'System default', - nextAccountLabel: 'System default', - previousAccountId: null, - nextAccountId: null, - homeRouteChanged: true - } - ]) - - expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ - previousAccountLabel: 'System default', - nextAccountLabel: 'System default', - previousAccountId: null, - nextAccountId: null, - homeRouteChanged: true - }) - }) - it('re-asks a dismissed pane when a same-labelled third account is selected', () => { switchAccountById('pty-1', 'account-a', 'account-b') useAppStore.getState().dismissCodexRestartNotices(['pty-1']) diff --git a/src/renderer/src/store/slices/jira-web-client-status.test.ts b/src/renderer/src/store/slices/jira-web-client-status.test.ts new file mode 100644 index 00000000000..0415238f274 --- /dev/null +++ b/src/renderer/src/store/slices/jira-web-client-status.test.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand' +import { afterEach, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import { withFallback } from '@/web/preload-api/web-fallback-api' +import { createJiraSlice } from './jira' + +function createTestStore() { + return create()((...args) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Connection reads use only settings and the fully initialized Jira slice. + return { settings: null, ...createJiraSlice(...args) } as AppState + }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +it('keeps the paired web client disconnected when its unwired Jira preload resolves undefined', async () => { + // The web client's window.api has no `jira` namespace, so jira.status() hits this fallback. + vi.stubGlobal('window', { api: withFallback({}, []) }) + const store = createTestStore() + + await expect(store.getState().checkJiraConnection()).resolves.toBeUndefined() + + expect(store.getState().jiraStatus).toEqual({ connected: false, viewer: null }) + expect(store.getState().jiraStatusChecked).toBe(true) + expect(store.getState().jiraStatusContextKey).toBe('local#0') +}) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index af40e6967d2..57da30ce379 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -179,7 +179,6 @@ export type TerminalActions = { markCodexRestartNotices: ( notices: (Pick & Partial> & { - homeRouteChanged?: boolean ptyId: string })[] ) => string[] diff --git a/src/renderer/src/store/terminals/terminal-contracts.ts b/src/renderer/src/store/terminals/terminal-contracts.ts index f821e2a8764..7bb43481ad5 100644 --- a/src/renderer/src/store/terminals/terminal-contracts.ts +++ b/src/renderer/src/store/terminals/terminal-contracts.ts @@ -18,8 +18,6 @@ export type CodexRestartNotice = { /** Labels are display-only; account ids disambiguate equal labels and A→B→A collapse. */ previousAccountId?: string | null nextAccountId?: string | null - /** Persists a home-route mismatch after ephemeral launch-account memory has expired. */ - homeRouteChanged?: true /** Accepted restart remains tracked so a failed execution can reopen instead of input-blocking invisibly. */ restartRequested?: true /** Dismissal outlives the prompt while explicitly preventing that notice from blocking pane input. */ diff --git a/src/renderer/src/store/terminals/terminal-restart-state.ts b/src/renderer/src/store/terminals/terminal-restart-state.ts index 2b712b6ccdb..a3aa7312b85 100644 --- a/src/renderer/src/store/terminals/terminal-restart-state.ts +++ b/src/renderer/src/store/terminals/terminal-restart-state.ts @@ -92,13 +92,8 @@ export function createTerminalRestartActions( // describing two different accounts. const launch = existing ?? notice const target = { id: notice.nextAccountId, label: notice.nextAccountLabel } - const homeRouteChanged = - notice.homeRouteChanged === undefined - ? existing?.homeRouteChanged === true - : notice.homeRouteChanged // Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice. if ( - !homeRouteChanged && isSameCodexRestartNoticeAccount( { id: launch.previousAccountId, label: launch.previousAccountLabel }, target @@ -115,7 +110,6 @@ export function createTerminalRestartActions( ? {} : { previousAccountId: launch.previousAccountId }), ...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }), - ...(homeRouteChanged ? { homeRouteChanged: true as const } : {}), // Why: a queued restart relaunches under whatever account is selected // when it runs, so a later switch does not reopen an answered prompt. ...(existing?.restartRequested ? { restartRequested: true as const } : {}), 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 00000000000..2ad176e6932 --- /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 00000000000..0c6354f8398 --- /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 00000000000..d5edff6dc85 --- /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 00000000000..42d0b8f6376 --- /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 00000000000..60500173838 --- /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 035bb7d1d01..cb5e0e573d7 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 e5f8125a5c0..9613afa8a84 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 b60356965c0..41a9606deca 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 93a1409de03..1cd5a8e9e26 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 00000000000..2aaaac158ff --- /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 08fd7a90a60..0904ecf8551 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-outbox-withdrawn-send.test.ts b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts new file mode 100644 index 00000000000..214ed97872a --- /dev/null +++ b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts @@ -0,0 +1,78 @@ +// A send the host withdrew (a Stop took it back before it ran) leaves the outbox with no notice, so +// the next queued send goes out. One the host left in doubt holds the queue until its Retry. + +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' +import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function submission(patch: Partial): AgentJournalSubmission { + return { + clientMessageId: 'stopped', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...patch + } +} + +const OUTBOX = [entry('stopped', { state: 'dispatching', lastAttemptAt: 1 }), entry('next')] + +describe('the send a Stop ended before its turn opened', () => { + it('leaves the outbox when withdrawn, and the next send goes out', () => { + const withdrawn = submission({ + dispatchState: 'rejected', + recovered: true, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn], []) + + expect(reconciled.map((each) => each.clientMessageId)).toEqual(['next']) + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'dispatch', + entry: { clientMessageId: 'next' } + }) + }) + + it('holds the queue when left in doubt', () => { + const inDoubt = submission({ + dispatchState: 'unknown', + recovered: true, + reason: 'provider_closed_before_acknowledgement' + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt], []) + + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'blocked', + entry: { clientMessageId: 'stopped' } + }) + }) +}) diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts index 74279d7811d..9312c3d8e5b 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 00000000000..92ec054d142 --- /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 00000000000..42f5856e35c --- /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 b31c8f008cd..3fc06f9d2dc 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])