Merge #24080's new head (main with #24257 squashed) into brennanb2025/worker-brief-on-launch-line

This commit is contained in:
Brennan Benson
2026-10-05 11:46:02 -07:00
276 changed files with 7624 additions and 3860 deletions
+28 -8
View File
@@ -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": {
-2
View File
@@ -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,
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 92m">
<title>downloads: 92m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 93m">
<title>downloads: 93m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">92m</text>
<text x="90" y="14">92m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">93m</text>
<text x="90" y="14">93m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -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 {
@@ -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}
@@ -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)
})
})
})
@@ -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}
</Text>
) : null}
{permission.subject?.kind === 'plan' ? (
{isPlanApprovalSubject(permission.subject) ? (
<View>
<MobileMarkdown content={permission.subject.text} />
{permission.subject.filePath ? (
@@ -103,6 +109,11 @@ function MobileNativeChatPermissionImpl({
) : permission.detail ? (
<Text style={styles.detail}>{permission.detail}</Text>
) : null}
{newerSubject ? (
<Text testID="native-chat-approval-needs-newer-orca" style={styles.detail}>
This request needs a newer version of Orca.
</Text>
) : null}
</ScrollView>
) : null}
<View testID="native-chat-approval-actions" style={styles.options}>
@@ -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}
>
<Text style={[styles.optionText, isPrimary && styles.optionTextPrimary]}>
{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,
@@ -93,6 +93,9 @@ type Overrides = {
keyboardInset?: number
hasMore?: boolean
onLoadEarlier?: () => void
status?: Parameters<typeof MobileNativeChatView>[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' })
+16 -8
View File
@@ -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 ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
<Text style={styles.emptySubtitle}>{emptyState.subtitle}</Text>
</View>
) : 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 <View style={[styles.root, { paddingBottom: bottomPad }]}>{emptyStateView}</View>
}
return (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
@@ -354,14 +369,7 @@ export function MobileNativeChatView({
turns.waitingRows,
renderItem
)}
ListEmptyComponent={
emptyState ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
<Text style={styles.emptySubtitle}>{emptyState.subtitle}</Text>
</View>
) : null
}
ListEmptyComponent={emptyStateView}
/>
</GestureDetector>
{/* Jump-to-latest control. */}
@@ -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')
@@ -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 () => {
@@ -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. */
@@ -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<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const client: RpcClient = {
sendRequest,
subscribe: vi.fn<RpcClient['subscribe']>((_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<string, unknown>): 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<unknown> {
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')
})
@@ -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<typeof useMobileStructuredAgentSession> | null = null
let listener: ((value: unknown) => void) | null = null
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const client: RpcClient = {
sendRequest,
subscribe: vi.fn<RpcClient['subscribe']>((_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
})
})
@@ -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
@@ -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
@@ -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
})
@@ -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<void>[] = []
@@ -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 = {
@@ -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: {
+1 -4
View File
@@ -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) {
-5
View File
@@ -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
}
+1 -3
View File
@@ -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)
@@ -179,10 +179,8 @@ export function createClaudeJournalTranslator(
message: Record<string, unknown>,
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(
@@ -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',
@@ -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
}
+1 -4
View File
@@ -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
}
@@ -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,
@@ -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<string, string>()
@@ -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
@@ -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
@@ -62,7 +62,6 @@ function sessionWithRunningTurn() {
fence: 7,
acquisitionGeneration: 'generation-1',
threadId: 'thread-1',
historyPath: null,
prompts: new CodexPromptRegistry(),
options: new Map(),
reportedOptions: {},
@@ -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
)
}
@@ -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:<distro>' — 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 = {
+11 -44
View File
@@ -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<CodexEnvironmentHomeOverride>
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<CodexShellStartupHomeOverride>
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))
)
}
@@ -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({
+10 -39
View File
@@ -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: {
+6 -51
View File
@@ -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)
}
)
})
+22 -99
View File
@@ -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 })
)
}
@@ -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: {},
@@ -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],
+2 -5
View File
@@ -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
+25 -113
View File
@@ -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'
}
])
})
+4 -18
View File
@@ -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
@@ -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'])
})
@@ -44,6 +44,9 @@ export type CodexDispatchEchoes = {
answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet<string>) => 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>) => 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<string>,
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) {
@@ -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([])
})
@@ -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<StructuredAgentSessionAdapter['cancelTurn']>[0]
type AnswerInput = Parameters<StructuredAgentSessionAdapter['answerPrompt']>[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<typeof cancelCodexStructuredTurn>[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 }
@@ -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,
@@ -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 () => {
@@ -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 => {}
@@ -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<string | null> => this.sessions.get(input.identity.sessionId)?.historyPath ?? null
closeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
forceCloseSession = (sessionId: string): Promise<boolean> => this.teardown.forceClose(sessionId)
disposeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
@@ -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()
}
@@ -100,7 +100,6 @@ describe('Codex structured session close lifecycle', () => {
fence: 7,
acquisitionGeneration: 'generation-1',
threadId: THREAD,
historyPath: null,
prompts,
options: new Map(),
reportedOptions: {},
@@ -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' },
@@ -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'
})
@@ -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. */
@@ -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 {
@@ -95,7 +95,6 @@ describe('openCodexThread', () => {
).resolves.toEqual({
threadId: 'thread-standard',
thread: { id: 'thread-standard' },
historyPath: null,
serviceTier: null
})
})
@@ -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<string, unknown>
/** 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 }
: {}),
@@ -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<ReturnType<typeof codexTurnLifecycleRig>>
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<void> {
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)
})
})
@@ -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<string>
dispatchEchoes: Pick<CodexDispatchEchoes, 'answeredUnopenedTurn' | 'leftUnopened'>
dispatchEchoes: Pick<
CodexDispatchEchoes,
'answeredUnopenedTurn' | 'leftUnopened' | 'answeredTurnLeftUnopened'
>
turnOpenWaits: Pick<CodexTurnOpenWaits, 'wait'>
}): Promise<string | null> {
}
/** 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<string>()
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<AgentSessionCancelOutcome>,
/** Whether the session the Stop began on still runs it, read after the wait. */
stillCurrent: () => boolean
): Promise<AgentSessionCancelOutcome> {
const open = (): ReadonlySet<string> => session.activeTurnIds ?? new Set<string>()
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<string | null> {
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
@@ -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 () => {
+61 -14
View File
@@ -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<void> | 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')
}
+1 -2
View File
@@ -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 }) => {
@@ -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' }))
+1 -45
View File
@@ -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'
})
}
)
})
-36
View File
@@ -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
@@ -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?.()
})
-1
View File
@@ -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?.()
})
@@ -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)
})
@@ -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<string, ClosedValue[]>
/** 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<string>() }
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)
})
@@ -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<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
return journals.open({
identity: IDENTITY,
stateDirectory: root,
now: tick,
mintEpoch: () => `epoch-${clock}`,
...overrides
})
}
async function withJournalDatabase(run: (db: Database.Database) => void): Promise<void> {
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<JournalRow | null> {
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<number[]> {
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 })
})
})
@@ -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 }
@@ -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<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
return journals.open({
identity: IDENTITY,
stateDirectory: root,
now: () => (clock += 1),
mintEpoch: () => `epoch-${clock}`,
...overrides
})
}
function withJournalDatabase<T>(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<void> {
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')])
})
})
@@ -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. */
@@ -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([])
@@ -20,7 +20,6 @@ export class JournalEpochController {
serialize: <T>(run: JournalWriteBody<T>) => Promise<T>
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()
})
}
@@ -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 })
}
@@ -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 })
}
@@ -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.
@@ -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)
}
@@ -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(
@@ -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 }
@@ -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<typeof failLoadOnUnloadableJournal>[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
@@ -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<string, string>()
/** 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
})
}
@@ -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<JournalRow, { kind: 'epoch' }> | 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,
@@ -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()
@@ -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<PerSessionJournalImportOu
* The chat a first copy would import, folded straight from its per-chat file and copying nothing:
* for a restore, which must not import. Null when the open has to import now instead: the chat is
* already in the host's database or was copied before (the reimport rules decide), its file holds
* no chat, or its fold needs a repair written. The file is closed before this returns.
* no chat or no rows, or a newer build wrote it. A damaged file fails the load, copied nowhere. The
* file is closed before this returns.
*/
export async function previewPerSessionJournal(
input: Pick<ImportInput, 'database' | 'identity' | 'legacyDirectory' | 'openSource'>
@@ -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<void> {
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)
})
}
@@ -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<JournalLoad> {
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.
@@ -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)
@@ -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')
}
}
@@ -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
})
}
@@ -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' }] }
@@ -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)
})
})
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown> {
* 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<string, unknown>): 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<string, unknown>): 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<string, unknown>) => boolean
(record: Record<string, unknown>) => 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<string, unknown>): boolean {
@@ -375,18 +349,32 @@ function hasJournalRowEnvelope(record: Record<string, unknown>): 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<string, unknown>) => 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. */
@@ -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)
}
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -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 }
})
})
})
@@ -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
}
}
}
@@ -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<AgentJournalItemBody, { kind: 'turn' }>
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()
})
})
@@ -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<JournalQueuePauseMarks['latestStop']>
@@ -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<JournalReducerState, 'queuePauseMarks'>
): 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
}

Some files were not shown because too many files have changed in this diff Show More