mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,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'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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?.()
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user