mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Native chat: a message is never marked not delivered after a crash, and reopening a chat stops reading the transcript (#30580)
* Native chat: never mark a message not delivered after a crash; reopening a chat stops reading the transcript After a crash or restart, attach read a window of the agent's saved log and marked any unconfirmed message missing from it as rejected "not delivered". Absence from a log Orca does not own is not proof: the window's anchor moves every turn (STA-7416) and its text filters drop tagged prompts (STA-7453), so messages the agent received were marked undelivered and retyping them duplicated the question. The read also ran on every attach (STA-7434). Delete the inference: the provider history window and its adapter hooks (Claude window and branch proof, OpenCode stored-messages reader), the submission matcher, and the attach step. A send the crash boundary leaves unconfirmed stays an ordinary sent message; the interrupted turn still ends through the existing settlement. Explicit live refusals are unchanged. A queued card whose hand-off a restart left unconfirmed no longer counts as able to return to the queue, so it stops keeping an idle chat loaded. * test(native-chat): update restart fixture calls after history removal * Hide rewind targets without a saved native boundary * Run rewind boundary regression across host and renderer * Keep incoming main package-manager lock unchanged * Test recovered chat consumer outcomes without delivery inference * Assign the complete cursor in recovered phone regression fixture * Supply presentation capture in rewind boundary fixture
This commit is contained in:
@@ -412,6 +412,56 @@ describe('phone chat CLI fork creation owner', () => {
|
||||
expect(resolution?.isTabChatView('ordinary-tab')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports the strict saved-conversation refusal without opening a terminal or fallback', async () => {
|
||||
const scope = createScope(
|
||||
clientReturning({
|
||||
ok: false,
|
||||
error: { code: 'chat_fork_source_unavailable', message: 'chat_fork_source_unavailable' }
|
||||
})
|
||||
)
|
||||
scope.hostCapabilities = [SESSION_TABS_CHAT_FORK_CLI_RUNTIME_CAPABILITY]
|
||||
scope.sessionTabsRef.current = [
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'chat-tab',
|
||||
title: 'Chat',
|
||||
sessionId: 'chat-1',
|
||||
agent: 'claude',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
let actions: ReturnType<typeof useMobileSessionTerminalCreateActions> | undefined
|
||||
function Harness() {
|
||||
actions = useMobileSessionTerminalCreateActions(scope as never)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await act(async () => {
|
||||
expect(
|
||||
await actions?.handleCreateTerminal(undefined, {
|
||||
sourceChatFork: { sessionId: 'chat-1', agent: 'claude' }
|
||||
})
|
||||
).toBe('reported-failure')
|
||||
})
|
||||
expect(scope.client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(scope.client.sendRequest).toHaveBeenCalledWith(
|
||||
'session.tabs.createTerminal',
|
||||
expect.objectContaining({
|
||||
sourceChatFork: { sessionId: 'chat-1' }
|
||||
}),
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
expect(scope.setActiveSessionTabId).not.toHaveBeenCalled()
|
||||
expect(scope.setActiveHandle).not.toHaveBeenCalled()
|
||||
expect(scope.setSessionTabs).not.toHaveBeenCalled()
|
||||
expect(scope.nativeChatController.setTabView).not.toHaveBeenCalled()
|
||||
expect(scope.setCreateError).toHaveBeenCalledWith(
|
||||
"Could not open Claude in a new CLI. Check that the chat has a saved conversation and review the agent's command and settings on the host, then try again."
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a busy create before sending the chat intent', async () => {
|
||||
const scope = createScope(clientReturning())
|
||||
scope.hostCapabilities = [SESSION_TABS_CHAT_FORK_CLI_RUNTIME_CAPABILITY]
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type StructuredAgentSessionHostSupport
|
||||
} from './mobile-structured-agent-session-host-support'
|
||||
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
|
||||
import { mobileNativeChatUnsentNotices } from './mobile-native-chat-unsent-notices'
|
||||
import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
|
||||
import {
|
||||
useMobileNativeChatSendError,
|
||||
@@ -270,16 +271,17 @@ describe('mobile structured send actions', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the original unknown host row while allowing another Send', async () => {
|
||||
it('shows a recovered unknown once and a new Send and reply without replay', async () => {
|
||||
const original = structuredSendResultFixture('unknown')
|
||||
if (!('submission' in original)) {
|
||||
throw new Error('expected a submission answer')
|
||||
}
|
||||
original.submission.recovered = true
|
||||
const event = snapshotEvent()
|
||||
event.page.submissions = [original.submission]
|
||||
event.page.items = [
|
||||
{
|
||||
itemId: 'original-message',
|
||||
itemId: agentJournalSubmissionKey(original.submission.clientMessageId),
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 10,
|
||||
@@ -293,6 +295,10 @@ describe('mobile structured send actions', () => {
|
||||
act(() => listener?.(event))
|
||||
const messages = hook!.session.messages
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.unsent).toBeUndefined()
|
||||
expect(hook!.isWorking).toBe(false)
|
||||
expect(mobileNativeChatUnsentNotices(event.page, agent).size).toBe(0)
|
||||
expect(renderer!.root.findByType('span').children).toEqual([])
|
||||
await act(async () => {
|
||||
expect(await hook!.sendWithOutcome('same text')).toBe('accepted')
|
||||
})
|
||||
@@ -300,7 +306,42 @@ describe('mobile structured send actions', () => {
|
||||
const sent = agentJournalSubmissionKey(sentIds()[0] ?? '')
|
||||
expect(hook!.session.messages.map((message) => message.id)).toEqual([messages[0]?.id, sent])
|
||||
expect(hook!.session.messages[0]).toEqual(messages[0])
|
||||
expect(event.page.submissions[0]?.dispatchState).toBe('unknown')
|
||||
const followUp = structuredSendResultFixture('accepted')
|
||||
if (!('submission' in followUp)) {
|
||||
throw new Error('expected a submission answer')
|
||||
}
|
||||
const newId = sentIds()[0]!
|
||||
expect(newId).not.toBe(original.submission.clientMessageId)
|
||||
followUp.submission.clientMessageId = newId
|
||||
const reply = snapshotEvent()
|
||||
reply.page.submissions = [original.submission, followUp.submission]
|
||||
reply.page.items = [
|
||||
...event.page.items,
|
||||
{ ...event.page.items[0]!, itemId: sent, sequence: 2, observedAt: 20 },
|
||||
{
|
||||
itemId: 'assistant-reply',
|
||||
revision: 1,
|
||||
sequence: 3,
|
||||
observedAt: 30,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'Reply to the new send' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
reply.page.liveCursor = { epoch: 'epoch-1', sequence: 3 }
|
||||
act(() => listener?.(reply))
|
||||
expect(hook!.session.messages.map((message) => message.id)).toEqual([
|
||||
messages[0]?.id,
|
||||
sent,
|
||||
'assistant-reply'
|
||||
])
|
||||
expect(hook!.session.messages.at(-1)?.blocks).toEqual([
|
||||
{ type: 'text', text: 'Reply to the new send' }
|
||||
])
|
||||
expect(hook!.session.messages[0]).toEqual(messages[0])
|
||||
expect(reply.page.submissions[0]).toMatchObject({ dispatchState: 'unknown', recovered: true })
|
||||
expect(calls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import { ANTIGRAVITY_ACP_DIALECT } from './acp-dialects/antigravity-dialect'
|
||||
import { directoryAccountBinding, type AcpAccountBinding } from './acp-account-binding'
|
||||
import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home'
|
||||
import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment'
|
||||
import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-stored-messages'
|
||||
import type { AcpStoredUserMessagesReader } from './acp-recovery-history'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { AgentSessionModelOption } from '../../shared/agent-session-wire'
|
||||
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
|
||||
@@ -127,8 +125,6 @@ export type AcpLaunchSpec = {
|
||||
compaction?: true
|
||||
/** Rejects copy arguments that route the CLI away from the copy's store. */
|
||||
assertCopyLaunchArgs?: (args: readonly string[]) => void
|
||||
/** Used only for restart recovery. */
|
||||
readStoredUserMessages?: AcpStoredUserMessagesReader
|
||||
modelDiscovery: AcpModelDiscovery
|
||||
visualsSkill?: AcpVisualsSkillLoader
|
||||
transcriptImport: StructuredAgentTranscriptImport | null
|
||||
@@ -191,7 +187,6 @@ function openCodeLaunchSpec(
|
||||
loginCommand: agentSessionSignInFor(row.agent)?.loginCommand ?? [],
|
||||
account: openCodeAcpAccountBinding(),
|
||||
installDirectories: ({ homePath }) => [join(homePath, '.opencode', 'bin')],
|
||||
readStoredUserMessages: openCodeStoredUserMessagesReader(),
|
||||
visualsSkill: loadOpenCodeVisualsSkill,
|
||||
compaction: true,
|
||||
assertCopyLaunchArgs: assertOpenCodeCopyUsesLocalStore,
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
||||
import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open'
|
||||
import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer'
|
||||
import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler'
|
||||
import { acpLaunchSpecFor } from './acp-launch-specs'
|
||||
import { readAcpRecoveryHistory, type AcpStoredUserMessage } from './acp-recovery-history'
|
||||
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
|
||||
|
||||
const SESSION = 'session-alpha-1'
|
||||
const identity = {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'opencode',
|
||||
providerHandle: null
|
||||
}
|
||||
|
||||
function fingerprint(text: string): string {
|
||||
return computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: SESSION,
|
||||
fields: { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } }
|
||||
})
|
||||
}
|
||||
|
||||
function submission(
|
||||
clientMessageId: string,
|
||||
text: string,
|
||||
dispatchState: AgentJournalSubmission['dispatchState'],
|
||||
submittedAt: number,
|
||||
resolvedAt: number | null = null
|
||||
): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId,
|
||||
fence: 1,
|
||||
payloadFingerprint: fingerprint(text),
|
||||
dispatchState,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt,
|
||||
resolvedAt
|
||||
}
|
||||
}
|
||||
|
||||
function journal(...submissions: AgentJournalSubmission[]): JournalLoad {
|
||||
const state = createJournalReducerState(SESSION, 'epoch-1')
|
||||
for (const entry of submissions) {
|
||||
state.submissions.set(entry.clientMessageId, entry)
|
||||
}
|
||||
return { state, newer: null, damage: null }
|
||||
}
|
||||
|
||||
const launch: AcpStructuredLaunch = {
|
||||
spec: acpLaunchSpecFor('opencode')!,
|
||||
command: '/resolved/opencode',
|
||||
args: ['acp'],
|
||||
cwd: '/repo',
|
||||
env: { XDG_DATA_HOME: '/data' },
|
||||
envToDelete: [],
|
||||
fullAccess: false,
|
||||
resume: {
|
||||
sessionId: 'ses_1',
|
||||
key: 'acp:opencode:ses_1',
|
||||
mayBeUnsaved: () => false,
|
||||
unannouncedLosses: () => []
|
||||
}
|
||||
}
|
||||
|
||||
function stored(id: string, text: string, createdAt: number): AcpStoredUserMessage {
|
||||
return { id, blocks: [{ type: 'text', text }], createdAt }
|
||||
}
|
||||
|
||||
async function windowFor(load: JournalLoad | null, messages: AcpStoredUserMessage[] | null) {
|
||||
const readStoredUserMessages = vi.fn(async () => messages)
|
||||
const window = await readAcpRecoveryHistory(
|
||||
{
|
||||
spec: { readStoredUserMessages },
|
||||
readLaunch: async () => launch,
|
||||
readJournal: () => load
|
||||
},
|
||||
identity
|
||||
)
|
||||
return { window, readStoredUserMessages }
|
||||
}
|
||||
|
||||
describe('ACP restart recovery from the agent store', () => {
|
||||
it('settles a message the agent recorded after the crash as sent, under its own row', async () => {
|
||||
const load = journal(
|
||||
submission('first', 'hello', 'accepted', 100, 110),
|
||||
submission('held', 'run the tests', 'unknown', 200)
|
||||
)
|
||||
const { window, readStoredUserMessages } = await windowFor(load, [
|
||||
stored('msg_1', 'hello', 105),
|
||||
stored('msg_2', 'run the tests', 205)
|
||||
])
|
||||
expect(readStoredUserMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: launch.env, providerSessionId: 'ses_1' })
|
||||
)
|
||||
expect(window).toMatchObject({ boundaryConsistent: false, turnInFlight: true })
|
||||
expect(
|
||||
reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! })
|
||||
).toEqual([
|
||||
{
|
||||
clientMessageId: 'held',
|
||||
outcome: 'accepted',
|
||||
providerItemId: 'msg_2',
|
||||
identity: { provider: 'orca', clientMessageId: 'held' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('never offers an older identical message as the one in doubt', async () => {
|
||||
const load = journal(
|
||||
submission('first', 'continue', 'accepted', 100, 110),
|
||||
submission('held', 'continue', 'unknown', 200)
|
||||
)
|
||||
const { window } = await windowFor(load, [stored('msg_1', 'continue', 105)])
|
||||
expect(window?.items).toEqual([])
|
||||
expect(
|
||||
reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! })
|
||||
).toMatchObject([{ clientMessageId: 'held', outcome: 'unknown' }])
|
||||
})
|
||||
|
||||
it('lets an accepted send claim its own copy even when the agent stored it after the next send', async () => {
|
||||
// Accepted at dispatch (r=500) and stored at 505; the identical send behind it was lost.
|
||||
const load = journal(
|
||||
submission('first', 'continue', 'accepted', 100, 500),
|
||||
submission('held', 'continue', 'unknown', 110)
|
||||
)
|
||||
const { window } = await windowFor(load, [stored('msg_1', 'continue', 505)])
|
||||
expect(window?.items).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps an older unconfirmed send from widening the window over a delivered copy', async () => {
|
||||
const load = journal(
|
||||
submission('old', 'yes', 'unknown', 50),
|
||||
submission('first', 'yes', 'accepted', 300, 300),
|
||||
submission('held', 'yes', 'unknown', 400)
|
||||
)
|
||||
const { window } = await windowFor(load, [stored('msg_1', 'yes', 303)])
|
||||
expect(window?.items).toEqual([])
|
||||
})
|
||||
|
||||
it('confirms the doubted send when both identical copies are stored', async () => {
|
||||
const load = journal(
|
||||
submission('first', 'yes', 'accepted', 300, 300),
|
||||
submission('held', 'yes', 'unknown', 400)
|
||||
)
|
||||
const { window } = await windowFor(load, [
|
||||
stored('msg_1', 'yes', 303),
|
||||
stored('msg_2', 'yes', 405)
|
||||
])
|
||||
expect(
|
||||
reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! })
|
||||
).toMatchObject([{ clientMessageId: 'held', outcome: 'accepted', providerItemId: 'msg_2' }])
|
||||
})
|
||||
|
||||
it('reads nothing for a queued card the agent was never handed', async () => {
|
||||
const queued = { ...submission('queued', 'x', 'pending', 1), handoverRecorded: true as const }
|
||||
const { window, readStoredUserMessages } = await windowFor(journal(queued), [])
|
||||
expect(window).toBeNull()
|
||||
expect(readStoredUserMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads nothing with no message in doubt, a damaged journal, or no resumable session', async () => {
|
||||
const accepted = await windowFor(journal(submission('a', 'x', 'accepted', 1, 2)), [])
|
||||
expect(accepted.window).toBeNull()
|
||||
expect(accepted.readStoredUserMessages).not.toHaveBeenCalled()
|
||||
const damaged = journal(submission('held', 'x', 'unknown', 1))
|
||||
const broken = await windowFor(
|
||||
{ ...damaged, damage: { sequence: 3, cause: 'sequence-gap' } },
|
||||
[]
|
||||
)
|
||||
expect(broken.window).toBeNull()
|
||||
expect(broken.readStoredUserMessages).not.toHaveBeenCalled()
|
||||
const readStoredUserMessages = vi.fn(async () => [])
|
||||
await expect(
|
||||
readAcpRecoveryHistory(
|
||||
{
|
||||
spec: { readStoredUserMessages },
|
||||
readLaunch: async () => ({ ...launch, resume: null }),
|
||||
readJournal: () => journal(submission('held', 'x', 'unknown', 1))
|
||||
},
|
||||
identity
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
expect(readStoredUserMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads nothing for an agent whose store Orca does not read', async () => {
|
||||
await expect(
|
||||
readAcpRecoveryHistory(
|
||||
{
|
||||
spec: {},
|
||||
readLaunch: async () => launch,
|
||||
readJournal: () => journal(submission('held', 'x', 'unknown', 1))
|
||||
},
|
||||
identity
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('answers null, never throws, when the store cannot be read', async () => {
|
||||
const warn = vi.fn()
|
||||
await expect(
|
||||
readAcpRecoveryHistory(
|
||||
{
|
||||
spec: {
|
||||
readStoredUserMessages: async () => {
|
||||
throw new Error('database is locked')
|
||||
}
|
||||
},
|
||||
readLaunch: async () => launch,
|
||||
readJournal: () => journal(submission('held', 'x', 'unknown', 1)),
|
||||
logger: { warn, error: vi.fn() }
|
||||
},
|
||||
identity
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
// Recovery proves delivery only by presence; missing history never proves non-delivery.
|
||||
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import { agentSessionSendBodyFingerprint } from '../../shared/structured-agent-session-send-mutation'
|
||||
import type {
|
||||
AgentJournalSubmission,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { NativeChatBlock } from '../../shared/native-chat-types'
|
||||
import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open'
|
||||
import type {
|
||||
ProviderHistoryItem,
|
||||
ProviderHistoryWindow
|
||||
} from '../native-chat/agent-session-journal/journal-submission-reconciler'
|
||||
import { isQueuedAgentJournalSubmission } from '../../shared/agent-session-queued-submission'
|
||||
import type { AcpLaunchSpec } from './acp-launch-specs'
|
||||
import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps'
|
||||
|
||||
const RECOVERY_READ_MS = 5_000
|
||||
|
||||
export type AcpStoredUserMessage = {
|
||||
/** Provider ID, claimed at most once. */
|
||||
id: string
|
||||
blocks: NativeChatBlock[]
|
||||
/** Epoch milliseconds on the execution host's clock. */
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export type AcpStoredUserMessagesReader = (input: {
|
||||
env: Record<string, string>
|
||||
providerSessionId: string
|
||||
signal: AbortSignal
|
||||
}) => Promise<AcpStoredUserMessage[] | null>
|
||||
|
||||
// Queued submissions never reached the agent.
|
||||
function sendsInDoubt(load: JournalLoad): AgentJournalSubmission[] {
|
||||
return [...load.state.submissions.values()].filter(
|
||||
(submission) =>
|
||||
(submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') &&
|
||||
!isQueuedAgentJournalSubmission(submission)
|
||||
)
|
||||
}
|
||||
|
||||
function fingerprintOf(sessionId: string, message: AcpStoredUserMessage): string {
|
||||
return agentSessionSendBodyFingerprint(sessionId, {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: message.blocks
|
||||
})
|
||||
}
|
||||
|
||||
/** Claim accepted sends' matching copies first to avoid falsely confirming identical sends in doubt. */
|
||||
function recoveryWindow(
|
||||
load: JournalLoad,
|
||||
inDoubt: readonly AgentJournalSubmission[],
|
||||
messages: readonly AcpStoredUserMessage[]
|
||||
): ProviderHistoryItem[] {
|
||||
const submissions = [...load.state.submissions.values()]
|
||||
const from = Math.min(...inDoubt.map((submission) => submission.submittedAt))
|
||||
const stored = messages
|
||||
.map((message) => ({ message, fingerprint: fingerprintOf(load.state.sessionId, message) }))
|
||||
.toSorted((left, right) => left.message.createdAt - right.message.createdAt)
|
||||
const claimed = new Set<number>()
|
||||
const accepted = submissions
|
||||
.filter((submission) => submission.dispatchState === 'accepted')
|
||||
.toSorted((left, right) => left.submittedAt - right.submittedAt)
|
||||
for (const submission of accepted) {
|
||||
const index = stored.findIndex(
|
||||
({ message, fingerprint }, at) =>
|
||||
!claimed.has(at) &&
|
||||
fingerprint === submission.payloadFingerprint &&
|
||||
message.createdAt >= submission.submittedAt
|
||||
)
|
||||
if (index !== -1) {
|
||||
claimed.add(index)
|
||||
}
|
||||
}
|
||||
return stored.flatMap(({ message, fingerprint }, at) =>
|
||||
claimed.has(at) || message.createdAt < from
|
||||
? []
|
||||
: [{ providerItemId: message.id, clientMessageId: null, payloadFingerprint: fingerprint }]
|
||||
)
|
||||
}
|
||||
|
||||
export async function readAcpRecoveryHistory(
|
||||
input: Pick<AcpStructuredSessionAdapterDeps, 'readLaunch' | 'readJournal' | 'logger'> & {
|
||||
spec: Pick<AcpLaunchSpec, 'readStoredUserMessages'>
|
||||
},
|
||||
identity: AgentSessionJournalIdentity
|
||||
): Promise<ProviderHistoryWindow | null> {
|
||||
const { readStoredUserMessages } = input.spec
|
||||
if (!readStoredUserMessages || !input.readJournal) {
|
||||
return null
|
||||
}
|
||||
const sessionId = identity.sessionId
|
||||
const signal = AbortSignal.timeout(RECOVERY_READ_MS)
|
||||
try {
|
||||
const load = input.readJournal(sessionId)
|
||||
const inDoubt = load && !load.damage && !load.newer ? sendsInDoubt(load) : []
|
||||
if (!load || inDoubt.length === 0) {
|
||||
return null
|
||||
}
|
||||
const launch = await waitForPromiseWithSignal(input.readLaunch(identity), signal)
|
||||
if (!launch.resume) {
|
||||
return null
|
||||
}
|
||||
const messages = await waitForPromiseWithSignal(
|
||||
readStoredUserMessages({
|
||||
env: launch.env,
|
||||
providerSessionId: launch.resume.sessionId,
|
||||
signal
|
||||
}),
|
||||
signal
|
||||
)
|
||||
if (!messages) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
// Neither the read boundary nor completion of the agent's work is known.
|
||||
boundaryConsistent: false,
|
||||
turnInFlight: true,
|
||||
items: recoveryWindow(load, inDoubt, messages)
|
||||
}
|
||||
} catch (error) {
|
||||
input.logger?.warn('ACP recovery history could not be read', {
|
||||
scope: 'acp-recovery-history',
|
||||
sessionId,
|
||||
error
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words'
|
||||
import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open'
|
||||
import type { AcpLaunchSpec } from './acp-launch-specs'
|
||||
import type { ConnectAcpAgent } from './acp-structured-connection'
|
||||
import type { AcpStructuredLaunch } from './acp-structured-launch-resolution'
|
||||
@@ -26,12 +25,10 @@ export type AcpStructuredSessionAdapterDeps = {
|
||||
/** Defaults to ask. */
|
||||
permissionMode?: AgentChatPermissionMode
|
||||
}) => Promise<AcpStructuredLaunch>
|
||||
/** Recovery reads use the same launch with an unsealed environment. */
|
||||
/** The same launch for a read that starts no child (a conversation copy): the agent's env, unsealed. */
|
||||
readLaunch: (identity: AgentSessionJournalIdentity) => Promise<AcpStructuredLaunch>
|
||||
/** Current host arguments before protocol-specific filtering; copies open through the CLI. */
|
||||
resolveLaunchArgs?: () => Promise<string[]> | string[]
|
||||
/** Reads without opening the journal. */
|
||||
readJournal?: (sessionId: string) => JournalLoad | null
|
||||
/** Protected by this host's instance lock. */
|
||||
dataDirectory: string
|
||||
connect: ConnectAcpAgent
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
type AcpStructuredSessionAdapterDeps
|
||||
} from './acp-structured-session-adapter-deps'
|
||||
import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options'
|
||||
import { readAcpRecoveryHistory } from './acp-recovery-history'
|
||||
import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop'
|
||||
import { acpStartupFailure, isAcpReadySession } from './acp-structured-child'
|
||||
import { AcpStructuredChildren } from './acp-structured-child-lifecycle'
|
||||
@@ -45,10 +44,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
|
||||
this.copies = new AcpConversationCopies(deps)
|
||||
}
|
||||
|
||||
providerHistoryWindow: NonNullable<StructuredAgentSessionAdapter['providerHistoryWindow']> = (
|
||||
input
|
||||
) => readAcpRecoveryHistory(this.deps, input.identity)
|
||||
|
||||
copyConversation: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = (input) =>
|
||||
this.copies.copy(input)
|
||||
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
path: '',
|
||||
growth: '',
|
||||
readError: false,
|
||||
bytesRead: 0,
|
||||
peakChunkBytes: 0,
|
||||
streams: 0,
|
||||
closes: 0,
|
||||
opens: 0,
|
||||
observedStatBytes: 0
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const fs = await importOriginal<typeof FsPromises>()
|
||||
return {
|
||||
...fs,
|
||||
open: async (path: string, flags: string) => {
|
||||
const handle = await fs.open(path, flags)
|
||||
if (path !== state.path) {
|
||||
return handle
|
||||
}
|
||||
state.opens += 1
|
||||
return {
|
||||
stat: async () => {
|
||||
const snapshot = await handle.stat()
|
||||
state.observedStatBytes = snapshot.size
|
||||
if (state.growth) {
|
||||
const growth = state.growth
|
||||
state.growth = ''
|
||||
await fs.appendFile(path, growth)
|
||||
}
|
||||
return snapshot
|
||||
},
|
||||
createReadStream: (options: Parameters<FileHandle['createReadStream']>[0]) => {
|
||||
state.streams += 1
|
||||
const stream = handle.createReadStream(options)
|
||||
if (state.readError) {
|
||||
queueMicrotask(() => stream.destroy(new Error('Injected read failure')))
|
||||
return stream
|
||||
}
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
const size = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk)
|
||||
state.bytesRead += size
|
||||
state.peakChunkBytes = Math.max(state.peakChunkBytes, size)
|
||||
})
|
||||
return stream
|
||||
},
|
||||
close: async () => {
|
||||
state.closes += 1
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { readClaudeProviderHistoryWindow } from './claude-structured-history-window'
|
||||
|
||||
const LEGACY_LIMIT = 16 * 1024 * 1024
|
||||
const SOURCE = `${[
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'anchor',
|
||||
parentUuid: null,
|
||||
sessionId: 'provider',
|
||||
message: { role: 'user', content: 'before' }
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'latest',
|
||||
parentUuid: 'anchor',
|
||||
sessionId: 'provider',
|
||||
message: { role: 'user', content: 'after' }
|
||||
},
|
||||
{ type: 'last-prompt', sessionId: 'provider', leafUuid: 'latest' }
|
||||
]
|
||||
.map((row) => JSON.stringify(row))
|
||||
.join('\n')}\n`
|
||||
let directory = ''
|
||||
|
||||
const read = (previousLeafUuid: string | null = 'anchor') =>
|
||||
readClaudeProviderHistoryWindow({
|
||||
transcriptPath: state.path,
|
||||
providerSessionId: 'provider',
|
||||
previousLeafUuid,
|
||||
sessionId: 'orca',
|
||||
turnInFlight: false
|
||||
})
|
||||
|
||||
function padTo(bytes: number): string {
|
||||
const filler = `${JSON.stringify({ type: 'comment', note: 'x'.repeat(4096) })}\n`
|
||||
const rows = Math.ceil((bytes - Buffer.byteLength(SOURCE)) / Buffer.byteLength(filler))
|
||||
return filler.repeat(Math.max(rows, 0)) + SOURCE
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-history-source-budget-'))
|
||||
Object.assign(state, {
|
||||
path: join(directory, 'session.jsonl'),
|
||||
growth: '',
|
||||
readError: false,
|
||||
bytesRead: 0,
|
||||
peakChunkBytes: 0,
|
||||
streams: 0,
|
||||
opens: 0,
|
||||
closes: 0,
|
||||
observedStatBytes: 0
|
||||
})
|
||||
await writeFile(state.path, SOURCE)
|
||||
})
|
||||
|
||||
afterEach(async (context) => {
|
||||
try {
|
||||
const output = process.env.ORCA_HISTORY_BUDGET_PROOF_OUTPUT
|
||||
if (output) {
|
||||
await appendFile(
|
||||
output,
|
||||
`${JSON.stringify({
|
||||
test: context.task.name,
|
||||
bytesRead: state.bytesRead,
|
||||
peakChunkBytes: state.peakChunkBytes,
|
||||
observedStatBytes: state.observedStatBytes,
|
||||
streams: state.streams,
|
||||
opens: state.opens,
|
||||
closes: state.closes
|
||||
})}\n`
|
||||
)
|
||||
}
|
||||
expect(state.closes).toBe(state.opens)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Claude provider history source budget', () => {
|
||||
it('reads a stable history without changing prompt evidence', async () => {
|
||||
const result = await read()
|
||||
expect(result.boundaryConsistent).toBe(true)
|
||||
expect(result.items.map((item) => item.providerItemId)).toEqual(['latest'])
|
||||
})
|
||||
|
||||
it('streams two passes over one descriptor rather than one whole-file read', async () => {
|
||||
await read()
|
||||
// One open for both passes: pass 2 must see the bytes pass 1 vouched for.
|
||||
expect(state.opens).toBe(1)
|
||||
expect(state.streams).toBe(2)
|
||||
expect(state.bytesRead).toBe(2 * Buffer.byteLength(SOURCE))
|
||||
})
|
||||
|
||||
it('opens no second pass when nothing followed the anchor', async () => {
|
||||
await writeFile(
|
||||
state.path,
|
||||
SOURCE.split('\n')
|
||||
// Keep the marker so malformed input cannot make the no-replay assertion pass vacuously.
|
||||
.filter((line) => !line.includes('"uuid":"latest"'))
|
||||
.join('\n')
|
||||
.replace('"leafUuid":"latest"', '"leafUuid":"anchor"')
|
||||
)
|
||||
|
||||
const result = await read()
|
||||
|
||||
expect(result.boundaryConsistent).toBe(true)
|
||||
expect(result.items).toEqual([])
|
||||
expect(state.streams).toBe(1)
|
||||
})
|
||||
|
||||
it('resolves a source past the legacy whole-file limit instead of refusing it', async () => {
|
||||
// The old bounded read returned an inconsistent boundary here, which is the
|
||||
// one answer reconciliation can never act on.
|
||||
await writeFile(state.path, padTo(LEGACY_LIMIT + 1))
|
||||
expect(state.observedStatBytes).toBe(0)
|
||||
|
||||
const result = await read()
|
||||
|
||||
expect(result.boundaryConsistent).toBe(true)
|
||||
expect(result.items.map((item) => item.providerItemId)).toEqual(['latest'])
|
||||
expect(state.bytesRead).toBeGreaterThan(2 * LEGACY_LIMIT)
|
||||
})
|
||||
|
||||
it('reads a large history in chunks smaller than 1 MiB', async () => {
|
||||
await writeFile(state.path, padTo(LEGACY_LIMIT + 1))
|
||||
expect((await read()).boundaryConsistent).toBe(true)
|
||||
// Bound chunks handed to the framer; this does not measure retained memory.
|
||||
expect(state.peakChunkBytes).toBeLessThan(1024 * 1024)
|
||||
})
|
||||
|
||||
it('refuses a single record too large to frame', async () => {
|
||||
const huge = `${JSON.stringify({ type: 'comment', note: 'x'.repeat(LEGACY_LIMIT) })}\n`
|
||||
await writeFile(state.path, huge + SOURCE)
|
||||
expect((await read()).boundaryConsistent).toBe(false)
|
||||
})
|
||||
|
||||
it('replays a concurrent repair at the grown size, not the pinned one', async () => {
|
||||
const grown = `${JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'grown',
|
||||
parentUuid: 'latest',
|
||||
sessionId: 'provider',
|
||||
message: { role: 'user', content: 'appended' }
|
||||
})}\n`
|
||||
const torn = Math.floor(grown.length / 2)
|
||||
await writeFile(state.path, SOURCE + grown.slice(0, torn))
|
||||
state.growth = grown.slice(torn)
|
||||
|
||||
const result = await read()
|
||||
|
||||
// The retry re-runs BOTH passes at the new size: a pass 2 left at the old
|
||||
// size would silently drop the record the proof just accepted.
|
||||
expect(result.boundaryConsistent).toBe(true)
|
||||
expect(result.items.map((item) => item.providerItemId)).toEqual(['latest', 'grown'])
|
||||
// One failed proof, a refreshed proof, then replay.
|
||||
expect(state.streams).toBe(3)
|
||||
expect(state.opens).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves the inconsistent result on a read error', async () => {
|
||||
state.readError = true
|
||||
expect((await read()).boundaryConsistent).toBe(false)
|
||||
})
|
||||
|
||||
it('does not open a source without an anchor', async () => {
|
||||
expect((await read(null)).boundaryConsistent).toBe(false)
|
||||
expect(state.opens).toBe(0)
|
||||
expect(state.bytesRead).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,322 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { structuredAgentSessionSendBody } from '../../shared/structured-agent-session-send-mutation'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../shared/structured-agent-session-mutation'
|
||||
import { reconcileSubmissions } from '../native-chat/agent-session-journal/journal-submission-reconciler'
|
||||
import {
|
||||
claudeProviderHistoryWindowFromJsonl,
|
||||
resolveClaudeProviderHistoryWindow
|
||||
} from './claude-structured-history-window'
|
||||
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
const PROVIDER_SESSION = 'provider-1'
|
||||
const ORCA_SESSION = 'session-1'
|
||||
|
||||
let accountHome: string
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
|
||||
function prompt(uuid: string, parentUuid: string | null, content: unknown, extra: Row = {}): Row {
|
||||
return {
|
||||
type: 'user',
|
||||
uuid,
|
||||
parentUuid,
|
||||
sessionId: PROVIDER_SESSION,
|
||||
message: { role: 'user', content },
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function jsonl(rows: Row[], leafUuid: string): string {
|
||||
const lines = [...rows, { type: 'last-prompt', sessionId: PROVIDER_SESSION, leafUuid }]
|
||||
return `${lines.map((row) => JSON.stringify(row)).join('\n')}\n`
|
||||
}
|
||||
|
||||
function read(contents: string, previousLeafUuid: string | null, turnInFlight = false) {
|
||||
return claudeProviderHistoryWindowFromJsonl({
|
||||
contents,
|
||||
providerSessionId: PROVIDER_SESSION,
|
||||
previousLeafUuid,
|
||||
sessionId: ORCA_SESSION,
|
||||
turnInFlight
|
||||
})
|
||||
}
|
||||
|
||||
function sendFingerprint(text: string): string {
|
||||
return structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: ORCA_SESSION,
|
||||
fields: { body: structuredAgentSessionSendBody(text, []) }
|
||||
})
|
||||
}
|
||||
|
||||
const ANCHOR = prompt('anchor', null, 'earlier turn')
|
||||
|
||||
beforeEach(async () => {
|
||||
accountHome = await mkdtemp(join(tmpdir(), 'orca-claude-history-window-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(accountHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('claudeProviderHistoryWindowFromJsonl', () => {
|
||||
it('resolves history from the session account home, not the process default', async () => {
|
||||
const transcriptPath = join(accountHome, 'projects', 'work', `${PROVIDER_SESSION}.jsonl`)
|
||||
await mkdir(join(accountHome, 'projects', 'work'), { recursive: true })
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const window = await resolveClaudeProviderHistoryWindow({
|
||||
identity: {
|
||||
sessionId: ORCA_SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: claudeProviderHandle(PROVIDER_SESSION, 'anchor')
|
||||
},
|
||||
accountHomePath: accountHome,
|
||||
hasLiveSession: false
|
||||
})
|
||||
|
||||
expect(window?.items.map((item) => item.providerItemId)).toEqual(['u-1'])
|
||||
})
|
||||
|
||||
it('fingerprints a prompt after the anchor exactly as the send that produced it', () => {
|
||||
const contents = jsonl(
|
||||
[ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: 'ship it' }])],
|
||||
'u-1'
|
||||
)
|
||||
|
||||
const window = read(contents, 'anchor')
|
||||
|
||||
expect(window.boundaryConsistent).toBe(true)
|
||||
expect(window.items).toEqual([
|
||||
{
|
||||
providerItemId: 'u-1',
|
||||
clientMessageId: null,
|
||||
payloadFingerprint: sendFingerprint('ship it'),
|
||||
identity: { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'u-1' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('fingerprints a string-content prompt the same as a block-content one', () => {
|
||||
const asString = read(jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1'), 'anchor')
|
||||
|
||||
expect(asString.items[0]?.payloadFingerprint).toBe(sendFingerprint('ship it'))
|
||||
})
|
||||
|
||||
it('preserves leading whitespace when fingerprinting a text block', () => {
|
||||
const contents = jsonl(
|
||||
[ANCHOR, prompt('u-1', 'anchor', [{ type: 'text', text: ' ship it' }])],
|
||||
'u-1'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').items[0]?.payloadFingerprint).toBe(sendFingerprint(' ship it'))
|
||||
})
|
||||
|
||||
it('excludes everything before the anchor', () => {
|
||||
const contents = jsonl(
|
||||
[
|
||||
prompt('root', null, 'first'),
|
||||
prompt('anchor', 'root', 'second'),
|
||||
prompt('u-1', 'anchor', 'third')
|
||||
],
|
||||
'u-1'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-1'])
|
||||
})
|
||||
|
||||
it('reports no window and an inconsistent boundary without a durable anchor', () => {
|
||||
const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1')
|
||||
|
||||
expect(read(contents, null)).toEqual({
|
||||
items: [],
|
||||
boundaryConsistent: false,
|
||||
turnInFlight: false
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an inconsistent boundary when the anchor is gone from the file', () => {
|
||||
const contents = jsonl([prompt('u-1', null, 'ship it')], 'u-1')
|
||||
|
||||
expect(read(contents, 'anchor').boundaryConsistent).toBe(false)
|
||||
})
|
||||
|
||||
it('reports an inconsistent boundary when the leaf is on a sibling branch', () => {
|
||||
const contents = jsonl(
|
||||
[
|
||||
prompt('root', null, 'first'),
|
||||
prompt('anchor', 'root', 'second'),
|
||||
prompt('u-1', 'root', 'branched')
|
||||
],
|
||||
'u-1'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').boundaryConsistent).toBe(false)
|
||||
})
|
||||
|
||||
it('reports an inconsistent boundary on a torn tail', () => {
|
||||
const contents = `${jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1')}{"type":"user"`
|
||||
|
||||
expect(read(contents, 'anchor').boundaryConsistent).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the boundary consistent and the window empty when nothing followed the anchor', () => {
|
||||
expect(read(jsonl([ANCHOR], 'anchor'), 'anchor')).toEqual({
|
||||
items: [],
|
||||
boundaryConsistent: true,
|
||||
turnInFlight: false
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes harness-injected turns, meta turns, tool results and sidechains', () => {
|
||||
const contents = jsonl(
|
||||
[
|
||||
ANCHOR,
|
||||
prompt('u-reminder', 'anchor', [
|
||||
{ type: 'text', text: '<system-reminder>be careful</system-reminder>' }
|
||||
]),
|
||||
prompt('u-meta', 'u-reminder', [{ type: 'text', text: 'injected' }], { isMeta: true }),
|
||||
prompt('u-tool', 'u-meta', [{ type: 'tool_result', content: 'ok' }]),
|
||||
prompt('u-real', 'u-tool', 'ship it')
|
||||
],
|
||||
'u-real'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').items.map((item) => item.providerItemId)).toEqual(['u-real'])
|
||||
})
|
||||
|
||||
it('excludes a prompt carrying an image, whose path the transcript does not keep', () => {
|
||||
const contents = jsonl(
|
||||
[
|
||||
ANCHOR,
|
||||
prompt('u-img', 'anchor', [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }
|
||||
])
|
||||
],
|
||||
'u-img'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').items).toEqual([])
|
||||
expect(read(contents, 'anchor').boundaryConsistent).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the FIRST record for a repeated uuid, as the whole-file index did', () => {
|
||||
const contents = jsonl(
|
||||
[
|
||||
ANCHOR,
|
||||
prompt('u-1', 'anchor', 'first copy'),
|
||||
prompt('u-1', 'anchor', 'second copy'),
|
||||
prompt('u-2', 'u-1', 'next')
|
||||
],
|
||||
'u-2'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').items[0]?.payloadFingerprint).toBe(
|
||||
sendFingerprint('first copy')
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a repeated uuid whose first copy is not a prompt', () => {
|
||||
const contents = jsonl(
|
||||
[
|
||||
ANCHOR,
|
||||
prompt('u-1', 'anchor', 'injected', { isMeta: true }),
|
||||
prompt('u-1', 'anchor', 'real prompt')
|
||||
],
|
||||
'u-1'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').items).toEqual([])
|
||||
expect(read(contents, 'anchor').boundaryConsistent).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a malformed line rather than skipping past it into the window', () => {
|
||||
const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1').replace(
|
||||
'{"type":"last-prompt"',
|
||||
'not json\n{"type":"last-prompt"'
|
||||
)
|
||||
|
||||
expect(read(contents, 'anchor').boundaryConsistent).toBe(false)
|
||||
})
|
||||
|
||||
it('carries the caller-proven turn-in-flight fact through to the window', () => {
|
||||
const contents = jsonl([ANCHOR, prompt('u-1', 'anchor', 'ship it')], 'u-1')
|
||||
|
||||
expect(read(contents, 'anchor', true).turnInFlight).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a crash between Claude saving a prompt and Orca recording its echo', () => {
|
||||
const row = (type: string, uuid: string, parentUuid: string | null, extra: Row = {}): Row => ({
|
||||
type,
|
||||
uuid,
|
||||
parentUuid,
|
||||
isSidechain: false,
|
||||
sessionId: PROVIDER_SESSION,
|
||||
...extra
|
||||
})
|
||||
const marker = (leafUuid: string): Row => ({
|
||||
type: 'last-prompt',
|
||||
sessionId: PROVIDER_SESSION,
|
||||
leafUuid
|
||||
})
|
||||
const side = (type: string): Row => ({ type, sessionId: PROVIDER_SESSION })
|
||||
// Match CLI 2.1.280: a mid-turn crash leaves a prompt beyond the last stop-hook marker.
|
||||
const CRASHED_MID_TURN = [
|
||||
side('queue-operation'),
|
||||
row('attachment', 'hook-start', null, { attachment: { type: 'hook_success' } }),
|
||||
prompt('alpha', 'hook-start', [{ type: 'text', text: 'ALPHA' }]),
|
||||
row('attachment', 'alpha-context', 'alpha', { attachment: { type: 'date' } }),
|
||||
marker('alpha-context'),
|
||||
side('ai-title'),
|
||||
row('assistant', 'alpha-reply', 'alpha-context', {
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'ALPHA' }] }
|
||||
}),
|
||||
row('attachment', 'alpha-hook', 'alpha-reply', { attachment: { type: 'hook_success' } }),
|
||||
row('system', 'alpha-stop-summary', 'alpha-hook', { subtype: 'stop_hook_summary' }),
|
||||
marker('alpha-stop-summary'),
|
||||
side('queue-operation'),
|
||||
prompt('bravo', 'alpha-stop-summary', [{ type: 'text', text: 'BRAVO' }])
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n')
|
||||
|
||||
it('reconciles the prompt Claude already holds as accepted, not undelivered', () => {
|
||||
const window = read(`${CRASHED_MID_TURN}\n`, 'alpha-reply')
|
||||
expect(window).toMatchObject({ boundaryConsistent: true })
|
||||
expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo'])
|
||||
|
||||
const [verdict] = reconcileSubmissions({
|
||||
history: window,
|
||||
submissions: [
|
||||
{
|
||||
clientMessageId: 'bravo-send',
|
||||
fence: 1,
|
||||
payloadFingerprint: sendFingerprint('BRAVO'),
|
||||
dispatchState: 'unknown',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 0,
|
||||
resolvedAt: null
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(verdict).toMatchObject({ clientMessageId: 'bravo-send', outcome: 'accepted' })
|
||||
})
|
||||
|
||||
it('ends the conversation at the last main-chain row, never a trailing sidechain row', () => {
|
||||
const subagent = row('assistant', 'subagent-reply', null, { isSidechain: true })
|
||||
const window = read(`${CRASHED_MID_TURN}\n${JSON.stringify(subagent)}\n`, 'alpha-reply')
|
||||
expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo'])
|
||||
})
|
||||
})
|
||||
@@ -1,251 +0,0 @@
|
||||
// Reconcile sends against the transcript Claude resumes, from the durable anchor to the file tail.
|
||||
// Unproven ancestry makes absence inconclusive, so report an inconsistent boundary, not an empty window.
|
||||
|
||||
import { join } from 'node:path'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import {
|
||||
CLAUDE_STRUCTURED_HANDLE_NAMESPACE,
|
||||
claudeProviderHandleLeafUuid,
|
||||
isAgentSessionProviderHandleInNamespace
|
||||
} from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import type {
|
||||
ProviderHistoryItem,
|
||||
ProviderHistoryWindow
|
||||
} from '../native-chat/agent-session-journal/journal-submission-reconciler'
|
||||
import { claudeContentBlocks } from '../native-chat/transcript-record-blocks'
|
||||
import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
||||
import type { NativeChatBlock } from '../../shared/native-chat-types'
|
||||
import {
|
||||
replayClaudeTranscriptBranchAncestry,
|
||||
replayClaudeTranscriptBranchAncestryFromJsonl
|
||||
} from './claude-transcript-branch-proof'
|
||||
|
||||
// Bound each JSONL record without imposing a whole-file limit.
|
||||
const MAX_HISTORY_WINDOW_RECORD_BYTES = 16 * 1024 * 1024
|
||||
|
||||
const INCONSISTENT: ProviderHistoryWindow = {
|
||||
items: [],
|
||||
boundaryConsistent: false,
|
||||
turnInFlight: false
|
||||
}
|
||||
|
||||
type TranscriptRecord = Record<string, unknown>
|
||||
|
||||
function stringField(source: unknown, key: string): string | null {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return null
|
||||
}
|
||||
const value = (source as Record<string, unknown>)[key]
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
function isPlainTextPart(part: unknown): boolean {
|
||||
if (typeof part === 'string') {
|
||||
return true
|
||||
}
|
||||
return Boolean(part) && typeof part === 'object' && (part as TranscriptRecord).type === 'text'
|
||||
}
|
||||
|
||||
/** Recover the text bytes Claude received; the shared decoder trims for display. */
|
||||
function rawTextParts(content: unknown): string[] | null {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim() ? [content] : []
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string') {
|
||||
if (part.trim()) {
|
||||
parts.push(part)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const record = part && typeof part === 'object' ? (part as TranscriptRecord) : null
|
||||
if (!record || record.type !== 'text' || typeof record.text !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (record.text.trim()) {
|
||||
parts.push(record.text)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Exclude injected turns and attachments: transcript images lack the paths needed for send fingerprints. */
|
||||
function claudePromptBlocks(record: TranscriptRecord): NativeChatBlock[] | null {
|
||||
if (
|
||||
record.type !== 'user' ||
|
||||
record.isSidechain === true ||
|
||||
record.parent_tool_use_id != null ||
|
||||
record.isMeta === true ||
|
||||
record.isSynthetic === true ||
|
||||
record.isCompactSummary === true
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const message = record.message
|
||||
const content =
|
||||
message && typeof message === 'object' ? (message as TranscriptRecord).content : undefined
|
||||
// Inspect raw content because the display decoder omits images, which would change fingerprints.
|
||||
if (Array.isArray(content) && !content.every((part) => isPlainTextPart(part))) {
|
||||
return null
|
||||
}
|
||||
const blocks = claudeContentBlocks(content)
|
||||
if (blocks.length === 0 || blocks.some((block) => block.type !== 'text')) {
|
||||
return null
|
||||
}
|
||||
const rawTexts = rawTextParts(content)
|
||||
if (rawTexts === null || rawTexts.length !== blocks.length) {
|
||||
return null
|
||||
}
|
||||
const preservedBlocks = blocks.map((block, index) => ({
|
||||
...block,
|
||||
text: rawTexts[index]!
|
||||
}))
|
||||
const [first] = preservedBlocks
|
||||
if (first?.type !== 'text' || isKnownHarnessInjectedUserTurnText(first.text)) {
|
||||
return null
|
||||
}
|
||||
return preservedBlocks
|
||||
}
|
||||
|
||||
/** Use the same canonical fingerprint enforced at send admission. */
|
||||
function promptFingerprint(sessionId: string, blocks: NativeChatBlock[]): string {
|
||||
return computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId,
|
||||
fields: { body: { kind: 'message', role: 'user', blocks } }
|
||||
})
|
||||
}
|
||||
|
||||
type HistoryWindowInput = {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
/** Send fingerprints are scoped to the Orca session. */
|
||||
sessionId: string
|
||||
/** Absence is conclusive only when the caller proves no provider child can still append. */
|
||||
turnInFlight: boolean
|
||||
}
|
||||
|
||||
/** Retain fingerprints, not prompt bodies, while replaying the proven branch. */
|
||||
function createWindowCollector(input: HistoryWindowInput) {
|
||||
const byUuid = new Map<string, ProviderHistoryItem>()
|
||||
return { byUuid, onAncestorRecord }
|
||||
|
||||
function onAncestorRecord(record: TranscriptRecord, uuid: string): void {
|
||||
const blocks = claudePromptBlocks(record)
|
||||
if (!blocks) {
|
||||
return
|
||||
}
|
||||
byUuid.set(uuid, {
|
||||
providerItemId: uuid,
|
||||
// Claude omits client message IDs; reconciliation must use weaker fingerprint evidence.
|
||||
clientMessageId: null,
|
||||
payloadFingerprint: promptFingerprint(input.sessionId, blocks),
|
||||
identity: {
|
||||
provider: 'claude',
|
||||
sessionId: stringField(record, 'sessionId') ?? input.providerSessionId,
|
||||
uuid
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function windowFromChain(
|
||||
chain: readonly string[],
|
||||
byUuid: Map<string, ProviderHistoryItem>,
|
||||
turnInFlight: boolean
|
||||
): ProviderHistoryWindow {
|
||||
const items: ProviderHistoryItem[] = []
|
||||
for (const uuid of chain.toReversed()) {
|
||||
const item = byUuid.get(uuid)
|
||||
if (item) {
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
return { items, boundaryConsistent: true, turnInFlight }
|
||||
}
|
||||
|
||||
export function claudeProviderHistoryWindowFromJsonl(
|
||||
input: HistoryWindowInput & { contents: string }
|
||||
): ProviderHistoryWindow {
|
||||
const ancestryAnchorUuid = input.previousLeafUuid
|
||||
if (!ancestryAnchorUuid) {
|
||||
return INCONSISTENT
|
||||
}
|
||||
const collector = createWindowCollector(input)
|
||||
try {
|
||||
const { chain } = replayClaudeTranscriptBranchAncestryFromJsonl({
|
||||
contents: input.contents,
|
||||
providerSessionId: input.providerSessionId,
|
||||
previousLeafUuid: ancestryAnchorUuid,
|
||||
ancestryAnchorUuid,
|
||||
onAncestorRecord: collector.onAncestorRecord
|
||||
})
|
||||
return windowFromChain(chain, collector.byUuid, input.turnInFlight)
|
||||
} catch {
|
||||
// Unproven ancestry cannot establish that a send is absent.
|
||||
return INCONSISTENT
|
||||
}
|
||||
}
|
||||
|
||||
/** A live child can still append queued sends, so transcript absence cannot prove non-delivery. */
|
||||
export async function resolveClaudeProviderHistoryWindow(input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
accountHomePath: string
|
||||
hasLiveSession: boolean
|
||||
}): Promise<ProviderHistoryWindow | null> {
|
||||
const handle = input.identity.providerHandle
|
||||
if (
|
||||
!handle ||
|
||||
!isAgentSessionProviderHandleInNamespace(handle, CLAUDE_STRUCTURED_HANDLE_NAMESPACE)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const transcriptPath = await resolveSessionFilePath('claude', handle.nativeId, {
|
||||
claudeProjectsDir: join(input.accountHomePath, 'projects')
|
||||
})
|
||||
if (!transcriptPath) {
|
||||
return null
|
||||
}
|
||||
return readClaudeProviderHistoryWindow({
|
||||
transcriptPath,
|
||||
providerSessionId: handle.nativeId,
|
||||
previousLeafUuid: claudeProviderHandleLeafUuid(handle),
|
||||
sessionId: input.identity.sessionId,
|
||||
turnInFlight: input.hasLiveSession
|
||||
})
|
||||
}
|
||||
|
||||
export async function readClaudeProviderHistoryWindow(
|
||||
input: HistoryWindowInput & { transcriptPath: string }
|
||||
): Promise<ProviderHistoryWindow> {
|
||||
const ancestryAnchorUuid = input.previousLeafUuid
|
||||
if (!ancestryAnchorUuid) {
|
||||
return INCONSISTENT
|
||||
}
|
||||
const collector = createWindowCollector(input)
|
||||
try {
|
||||
const { chain } = await replayClaudeTranscriptBranchAncestry({
|
||||
transcriptPath: input.transcriptPath,
|
||||
providerSessionId: input.providerSessionId,
|
||||
previousLeafUuid: ancestryAnchorUuid,
|
||||
ancestryAnchorUuid,
|
||||
maxRecordBytes: MAX_HISTORY_WINDOW_RECORD_BYTES,
|
||||
onAncestorRecord: collector.onAncestorRecord
|
||||
})
|
||||
return windowFromChain(chain, collector.byUuid, input.turnInFlight)
|
||||
} catch (error) {
|
||||
// Read, ancestry, and record-size failures leave absence inconclusive.
|
||||
console.warn('[claude-history-window] transcript unprovable; boundary inconsistent:', {
|
||||
transcriptPath: input.transcriptPath,
|
||||
sessionId: input.sessionId,
|
||||
error
|
||||
})
|
||||
return INCONSISTENT
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home'
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import { dispatchClaudeCommand } from './claude-structured-command-dispatch'
|
||||
import type {
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
type ClaudeExitLifecycle
|
||||
} from './claude-structured-session-exit-lifecycle'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
|
||||
import { resolveClaudeProviderHistoryWindow } from './claude-structured-history-window'
|
||||
import { drainClaudeChildWork } from './claude-child-work-evidence'
|
||||
import { emitClaudeStructuredSessionEvent } from './claude-structured-event-delivery'
|
||||
import {
|
||||
@@ -118,17 +116,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
/** Await exit publication after cleanup/cursor writes; unproven exits stay indexed for retry. */
|
||||
drainObservedExits = (): Promise<void> => drainClaudeObservedExits(this.exits)
|
||||
|
||||
/** Restart reconciliation reads the transcript a resume replays; these maps track liveness. */
|
||||
providerHistoryWindow: NonNullable<StructuredAgentSessionAdapter['providerHistoryWindow']> = (
|
||||
input
|
||||
) =>
|
||||
resolveClaudeProviderHistoryWindow({
|
||||
identity: input.identity,
|
||||
accountHomePath: requireLegacyAgentSessionAccountHome(input.accountHome).path,
|
||||
hasLiveSession:
|
||||
this.sessions.has(input.identity.sessionId) || this.exits.has(input.identity.sessionId)
|
||||
})
|
||||
|
||||
private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void {
|
||||
emitClaudeStructuredSessionEvent({
|
||||
session,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ClaudeTranscriptMarkerMissingError,
|
||||
createBranchProof
|
||||
} from './claude-transcript-branch-graph'
|
||||
|
||||
const row = (uuid: string, parentUuid: string | null): string =>
|
||||
JSON.stringify({ type: 'user', uuid, parentUuid, sessionId: 'provider' })
|
||||
|
||||
function build(lines: string[], previousLeafUuid: string | null, tip?: 'marker' | 'file-tail') {
|
||||
const builder = createBranchProof({
|
||||
providerSessionId: 'provider',
|
||||
previousLeafUuid,
|
||||
...(tip === undefined ? {} : { tip })
|
||||
})
|
||||
for (const [index, line] of lines.entries()) {
|
||||
builder.add(line, index, true)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
describe('createBranchProof file-tail tip', () => {
|
||||
const MARKERLESS = [row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid')]
|
||||
|
||||
it('proves the tip from the last main-chain row with no last-prompt row anywhere', () => {
|
||||
expect(build(MARKERLESS, 'anchor', 'file-tail').finish()).toEqual({
|
||||
leafUuid: 'leaf',
|
||||
relation: 'descendant'
|
||||
})
|
||||
})
|
||||
|
||||
it('still requires the marker on the same bytes in marker mode', () => {
|
||||
expect(() => build(MARKERLESS, 'anchor').finish()).toThrow(ClaudeTranscriptMarkerMissingError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBranchProof ancestry chain', () => {
|
||||
const MARKER = JSON.stringify({ type: 'last-prompt', sessionId: 'provider', leafUuid: 'leaf' })
|
||||
|
||||
it('returns the leaf-first chain back to, but excluding, the anchor', () => {
|
||||
const builder = build(
|
||||
[row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid'), MARKER],
|
||||
'anchor'
|
||||
)
|
||||
builder.finish()
|
||||
|
||||
expect(builder.ancestryChain('leaf', 'anchor')).toEqual(['leaf', 'mid'])
|
||||
})
|
||||
|
||||
it('returns empty when the leaf IS the anchor', () => {
|
||||
const builder = build(
|
||||
[row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid'), MARKER],
|
||||
'anchor'
|
||||
)
|
||||
builder.finish()
|
||||
|
||||
expect(builder.ancestryChain('leaf', 'leaf')).toEqual([])
|
||||
})
|
||||
|
||||
it('throws rather than reporting empty when the walk never reaches the anchor', () => {
|
||||
const builder = build(
|
||||
[row('anchor', null), row('mid', 'anchor'), row('leaf', 'mid'), MARKER],
|
||||
'anchor'
|
||||
)
|
||||
builder.finish()
|
||||
|
||||
expect(() => builder.ancestryChain('leaf', 'absent')).toThrow('does not reach anchor absent')
|
||||
})
|
||||
})
|
||||
@@ -1,253 +0,0 @@
|
||||
const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000
|
||||
|
||||
class ClaudeTranscriptMarkerMissingError extends Error {
|
||||
constructor() {
|
||||
super('Claude transcript branch proof failed: missing last-prompt marker')
|
||||
}
|
||||
}
|
||||
|
||||
type TranscriptNode = {
|
||||
parentUuid: string | null
|
||||
sessionId: string | null
|
||||
lineIndex: number
|
||||
disallowedLeaf: boolean
|
||||
}
|
||||
|
||||
export type ClaudeTranscriptBranchProof = {
|
||||
leafUuid: string
|
||||
relation: 'initial' | 'same' | 'descendant'
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||
}
|
||||
|
||||
function transcriptError(reason: string): Error {
|
||||
return new Error(`Claude transcript branch proof failed: ${reason}`)
|
||||
}
|
||||
|
||||
export class ClaudeTranscriptTailIncompleteError extends Error {
|
||||
constructor() {
|
||||
super('Claude transcript branch proof failed: malformed JSONL')
|
||||
this.name = 'ClaudeTranscriptTailIncompleteError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeTranscriptPreviousCursorMissingError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Claude transcript branch proof failed: previous cursor is missing from the session graph'
|
||||
)
|
||||
this.name = 'ClaudeTranscriptPreviousCursorMissingError'
|
||||
}
|
||||
}
|
||||
|
||||
function proveMainLineAncestry(
|
||||
nodes: Map<string, TranscriptNode>,
|
||||
startUuid: string,
|
||||
providerSessionId: string
|
||||
): void {
|
||||
const visited = new Set<string>()
|
||||
let cursor: string | null = startUuid
|
||||
for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
if (visited.has(cursor)) {
|
||||
throw transcriptError('cycle in parentUuid ancestry')
|
||||
}
|
||||
visited.add(cursor)
|
||||
const node = nodes.get(cursor)
|
||||
if (!node || node.sessionId !== providerSessionId) {
|
||||
throw transcriptError(`missing ancestor ${cursor}`)
|
||||
}
|
||||
if (node.disallowedLeaf) {
|
||||
throw transcriptError(`ancestor ${cursor} is not on the main transcript`)
|
||||
}
|
||||
cursor = node.parentUuid
|
||||
}
|
||||
if (cursor !== null) {
|
||||
throw transcriptError('ancestry exceeds the bounded proof limit')
|
||||
}
|
||||
}
|
||||
|
||||
function proveAppendOrder(nodes: Map<string, TranscriptNode>): void {
|
||||
for (const node of nodes.values()) {
|
||||
if (!node.parentUuid) {
|
||||
continue
|
||||
}
|
||||
const parent = nodes.get(node.parentUuid)
|
||||
if (parent && parent.lineIndex >= node.lineIndex) {
|
||||
throw transcriptError('parent row follows descendant')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSCRIPT_TAIL_TYPES: ReadonlySet<unknown> = new Set([
|
||||
'user',
|
||||
'assistant',
|
||||
'system',
|
||||
'attachment'
|
||||
])
|
||||
|
||||
type BranchProofInput = {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
/** Defaults to marker; file-tail includes rows saved after a marker during a crash. */
|
||||
tip?: 'marker' | 'file-tail'
|
||||
}
|
||||
|
||||
function createBranchProof(input: BranchProofInput) {
|
||||
const nodes = new Map<string, TranscriptNode>()
|
||||
let leafUuid: string | null = null
|
||||
let leafMarkerLineIndex = -1
|
||||
let tailUuid: string | null = null
|
||||
let tailLineIndex = -1
|
||||
return { add, finish, ancestryChain }
|
||||
|
||||
function add(line: string, index: number, terminated: boolean): void {
|
||||
if (!line.trim()) {
|
||||
return
|
||||
}
|
||||
let record: unknown
|
||||
try {
|
||||
record = JSON.parse(line)
|
||||
} catch {
|
||||
if (!terminated) {
|
||||
throw new ClaudeTranscriptTailIncompleteError()
|
||||
}
|
||||
throw transcriptError('malformed JSONL')
|
||||
}
|
||||
if (typeof record !== 'object' || record === null || Array.isArray(record)) {
|
||||
throw transcriptError('non-object record')
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The parsed value is a non-array object checked above.
|
||||
const row = record as Record<string, unknown>
|
||||
if (row.type === 'last-prompt') {
|
||||
const markerSessionId = nonEmptyString(row.sessionId)
|
||||
const markerLeaf = nonEmptyString(row.leafUuid)
|
||||
if (markerSessionId !== input.providerSessionId || !markerLeaf) {
|
||||
throw transcriptError('invalid last-prompt marker')
|
||||
}
|
||||
leafUuid = markerLeaf
|
||||
leafMarkerLineIndex = index
|
||||
}
|
||||
const uuid = nonEmptyString(row.uuid)
|
||||
if (!uuid) {
|
||||
return
|
||||
}
|
||||
const parentUuid = row.parentUuid === null ? null : nonEmptyString(row.parentUuid)
|
||||
if (row.parentUuid !== null && !parentUuid) {
|
||||
throw transcriptError(`record ${uuid} has no parent identity`)
|
||||
}
|
||||
const sessionId = nonEmptyString(row.sessionId)
|
||||
const existing = nodes.get(uuid)
|
||||
const disallowedLeaf =
|
||||
row.isSidechain === true ||
|
||||
row.parent_tool_use_id != null ||
|
||||
row.type === 'result' ||
|
||||
row.type === 'stream_event' ||
|
||||
(row.type === 'system' && row.subtype === 'init')
|
||||
if (
|
||||
existing &&
|
||||
(existing.parentUuid !== parentUuid ||
|
||||
existing.sessionId !== sessionId ||
|
||||
existing.disallowedLeaf !== disallowedLeaf)
|
||||
) {
|
||||
throw transcriptError(`record ${uuid} has conflicting ancestry`)
|
||||
}
|
||||
nodes.set(uuid, {
|
||||
parentUuid,
|
||||
sessionId,
|
||||
lineIndex: existing?.lineIndex ?? index,
|
||||
disallowedLeaf
|
||||
})
|
||||
if (
|
||||
input.tip === 'file-tail' &&
|
||||
TRANSCRIPT_TAIL_TYPES.has(row.type) &&
|
||||
row.isSidechain !== true &&
|
||||
row.parent_tool_use_id == null
|
||||
) {
|
||||
tailUuid = uuid
|
||||
tailLineIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
function finish(): ClaudeTranscriptBranchProof {
|
||||
if (input.tip === 'file-tail' && tailUuid) {
|
||||
leafUuid = tailUuid
|
||||
leafMarkerLineIndex = tailLineIndex
|
||||
}
|
||||
if (!leafUuid) {
|
||||
throw new ClaudeTranscriptMarkerMissingError()
|
||||
}
|
||||
const leaf = nodes.get(leafUuid)
|
||||
if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) {
|
||||
throw transcriptError('marker leaf is missing from the session graph')
|
||||
}
|
||||
if (leaf.lineIndex > leafMarkerLineIndex) {
|
||||
throw transcriptError('marker precedes its leaf record')
|
||||
}
|
||||
const previousLeafUuid = input.previousLeafUuid
|
||||
if (!previousLeafUuid) {
|
||||
proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
|
||||
// Reject reordered rows: append-only ancestry requires each parent to precede its children.
|
||||
proveAppendOrder(nodes)
|
||||
return { leafUuid, relation: 'initial' }
|
||||
}
|
||||
const previous = nodes.get(previousLeafUuid)
|
||||
if (!previous) {
|
||||
throw new ClaudeTranscriptPreviousCursorMissingError()
|
||||
}
|
||||
if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) {
|
||||
throw transcriptError('previous cursor is not on the main transcript')
|
||||
}
|
||||
// Validate the old cursor too; it may itself descend through a disallowed sidechain.
|
||||
proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId)
|
||||
if (leafUuid === previousLeafUuid) {
|
||||
proveAppendOrder(nodes)
|
||||
return { leafUuid, relation: 'same' }
|
||||
}
|
||||
const visited = new Set<string>()
|
||||
let cursor: string | null = leafUuid
|
||||
for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
if (visited.has(cursor)) {
|
||||
throw transcriptError('cycle in parentUuid ancestry')
|
||||
}
|
||||
visited.add(cursor)
|
||||
const node = nodes.get(cursor)
|
||||
if (!node || node.sessionId !== input.providerSessionId) {
|
||||
throw transcriptError(`missing ancestor ${cursor}`)
|
||||
}
|
||||
if (node.disallowedLeaf) {
|
||||
throw transcriptError(`ancestor ${cursor} is not on the main transcript`)
|
||||
}
|
||||
cursor = node.parentUuid
|
||||
if (cursor === previousLeafUuid) {
|
||||
proveAppendOrder(nodes)
|
||||
return { leafUuid, relation: 'descendant' }
|
||||
}
|
||||
}
|
||||
if (cursor !== null) {
|
||||
throw transcriptError('ancestry exceeds the bounded proof limit')
|
||||
}
|
||||
throw transcriptError('latest marker is on a sibling branch')
|
||||
}
|
||||
|
||||
/** Call after finish(); a broken walk must throw because empty means nothing followed the anchor. */
|
||||
function ancestryChain(leafUuid: string, anchorUuid: string): string[] {
|
||||
const chain: string[] = []
|
||||
let cursor: string | null = leafUuid
|
||||
for (let depth = 0; cursor !== null && cursor !== anchorUuid; depth += 1) {
|
||||
if (depth >= MAX_CLAUDE_TRANSCRIPT_ANCESTRY || !nodes.has(cursor)) {
|
||||
throw transcriptError(`ancestry chain does not reach anchor ${anchorUuid}`)
|
||||
}
|
||||
chain.push(cursor)
|
||||
cursor = nodes.get(cursor)?.parentUuid ?? null
|
||||
}
|
||||
if (cursor !== anchorUuid) {
|
||||
throw transcriptError(`ancestry chain does not reach anchor ${anchorUuid}`)
|
||||
}
|
||||
return chain
|
||||
}
|
||||
}
|
||||
|
||||
export { ClaudeTranscriptMarkerMissingError, createBranchProof }
|
||||
export type { BranchProofInput }
|
||||
@@ -1,169 +0,0 @@
|
||||
// Pin one descriptor and size so ancestry proof and replay read the same append-only snapshot.
|
||||
|
||||
import { open } from 'node:fs/promises'
|
||||
import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines'
|
||||
import {
|
||||
ClaudeTranscriptMarkerMissingError,
|
||||
ClaudeTranscriptPreviousCursorMissingError,
|
||||
ClaudeTranscriptTailIncompleteError,
|
||||
createBranchProof,
|
||||
type BranchProofInput,
|
||||
type ClaudeTranscriptBranchProof
|
||||
} from './claude-transcript-branch-graph'
|
||||
|
||||
export {
|
||||
ClaudeTranscriptPreviousCursorMissingError,
|
||||
ClaudeTranscriptTailIncompleteError
|
||||
} from './claude-transcript-branch-graph'
|
||||
export type { ClaudeTranscriptBranchProof } from './claude-transcript-branch-graph'
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||
}
|
||||
|
||||
type AncestryRecord = Record<string, unknown>
|
||||
|
||||
export type ClaudeTranscriptBranchAncestry = {
|
||||
proof: ClaudeTranscriptBranchProof
|
||||
/** Leaf first, anchor excluded. */
|
||||
chain: string[]
|
||||
}
|
||||
|
||||
type AncestryInput = Omit<BranchProofInput, 'tip'> & {
|
||||
ancestryAnchorUuid: string
|
||||
/** First occurrence of each chain UUID, in file order. */
|
||||
onAncestorRecord: (record: AncestryRecord, uuid: string) => void
|
||||
}
|
||||
|
||||
function createAncestryReplay(
|
||||
chain: readonly string[],
|
||||
onRecord: (record: AncestryRecord, uuid: string) => void
|
||||
): (line: string) => void {
|
||||
const pending = new Set(chain)
|
||||
return (line) => {
|
||||
if (!line.trim()) {
|
||||
return
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
// The proof pass already rejected malformed lines in this snapshot.
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The parsed value is a non-array object checked above.
|
||||
const record = parsed as AncestryRecord
|
||||
const uuid = nonEmptyString(record.uuid)
|
||||
// Deletion enforces first-occurrence identity.
|
||||
if (uuid && pending.delete(uuid)) {
|
||||
onRecord(record, uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function proveClaudeTranscriptBranchFromJsonl(
|
||||
input: BranchProofInput & { contents: string }
|
||||
): ClaudeTranscriptBranchProof {
|
||||
const proof = createBranchProof(input)
|
||||
const lines = input.contents.split('\n')
|
||||
for (const [index, line] of lines.entries()) {
|
||||
proof.add(line, index, index < lines.length - 1)
|
||||
}
|
||||
return proof.finish()
|
||||
}
|
||||
|
||||
type PinnedTranscriptLines = () => AsyncGenerator<{ line: string; terminated: boolean }>
|
||||
|
||||
/** Retry the whole attempt once on growth; both passes must use the newly proven snapshot. */
|
||||
async function runPinnedTranscriptPasses<T>(
|
||||
transcriptPath: string,
|
||||
maxRecordBytes: number | undefined,
|
||||
attempt: (readLines: PinnedTranscriptLines) => Promise<T>
|
||||
): Promise<T> {
|
||||
const handle = await open(transcriptPath, 'r')
|
||||
try {
|
||||
let size = (await handle.stat()).size
|
||||
let refreshed = false
|
||||
while (true) {
|
||||
const pinned = size
|
||||
const readLines: PinnedTranscriptLines = async function* () {
|
||||
if (pinned === 0) {
|
||||
return
|
||||
}
|
||||
const stream = handle.createReadStream({ start: 0, end: pinned - 1, autoClose: false })
|
||||
yield* splitTranscriptStreamLines(stream, maxRecordBytes)
|
||||
}
|
||||
try {
|
||||
return await attempt(readLines)
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof ClaudeTranscriptMarkerMissingError) &&
|
||||
!(error instanceof ClaudeTranscriptPreviousCursorMissingError) &&
|
||||
!(error instanceof ClaudeTranscriptTailIncompleteError)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
if (refreshed) {
|
||||
throw new ClaudeTranscriptTailIncompleteError()
|
||||
}
|
||||
const nextSize = await handle.stat().then(
|
||||
(current) => current.size,
|
||||
() => size
|
||||
)
|
||||
if (nextSize <= size) {
|
||||
throw error
|
||||
}
|
||||
size = nextSize
|
||||
refreshed = true
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Prove before replay so growth retries cannot emit records twice; retain graph identities, not bodies. */
|
||||
export async function replayClaudeTranscriptBranchAncestry(
|
||||
input: AncestryInput & { transcriptPath: string; maxRecordBytes?: number }
|
||||
): Promise<ClaudeTranscriptBranchAncestry> {
|
||||
return runPinnedTranscriptPasses(
|
||||
input.transcriptPath,
|
||||
input.maxRecordBytes,
|
||||
async (readLines) => {
|
||||
const builder = createBranchProof({ ...input, tip: 'file-tail' })
|
||||
let index = 0
|
||||
for await (const record of readLines()) {
|
||||
builder.add(record.line, index++, record.terminated)
|
||||
}
|
||||
const proof = builder.finish()
|
||||
const chain = builder.ancestryChain(proof.leafUuid, input.ancestryAnchorUuid)
|
||||
if (chain.length > 0) {
|
||||
const replay = createAncestryReplay(chain, input.onAncestorRecord)
|
||||
for await (const record of readLines()) {
|
||||
replay(record.line)
|
||||
}
|
||||
}
|
||||
return { proof, chain }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function replayClaudeTranscriptBranchAncestryFromJsonl(
|
||||
input: AncestryInput & { contents: string }
|
||||
): ClaudeTranscriptBranchAncestry {
|
||||
const builder = createBranchProof({ ...input, tip: 'file-tail' })
|
||||
const lines = input.contents.split('\n')
|
||||
for (const [index, line] of lines.entries()) {
|
||||
builder.add(line, index, index < lines.length - 1)
|
||||
}
|
||||
const proof = builder.finish()
|
||||
const chain = builder.ancestryChain(proof.leafUuid, input.ancestryAnchorUuid)
|
||||
const replay = createAncestryReplay(chain, input.onAncestorRecord)
|
||||
for (const line of lines) {
|
||||
replay(line)
|
||||
}
|
||||
return { proof, chain }
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
type ReaderState = {
|
||||
path: string
|
||||
opens: number
|
||||
closes: number
|
||||
handles: FileHandle[]
|
||||
bytesRead: number
|
||||
statErrorOn: number
|
||||
readError: boolean
|
||||
afterStat: (() => Promise<void>) | null
|
||||
}
|
||||
|
||||
const state = vi.hoisted((): ReaderState => ({
|
||||
path: '',
|
||||
opens: 0,
|
||||
closes: 0,
|
||||
handles: [],
|
||||
bytesRead: 0,
|
||||
statErrorOn: 0,
|
||||
readError: false,
|
||||
afterStat: null
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const fs = await importOriginal<typeof FsPromises>()
|
||||
return {
|
||||
...fs,
|
||||
open: async (path: string, flags: string) => {
|
||||
const handle = await fs.open(path, flags)
|
||||
if (path !== state.path) {
|
||||
return handle
|
||||
}
|
||||
state.opens++
|
||||
state.handles.push(handle)
|
||||
let statsRead = 0
|
||||
return {
|
||||
stat: async () => {
|
||||
statsRead++
|
||||
if (statsRead === state.statErrorOn) {
|
||||
throw new Error('Injected stat failure')
|
||||
}
|
||||
const snapshot = await handle.stat()
|
||||
const afterStat = state.afterStat
|
||||
state.afterStat = null
|
||||
await afterStat?.()
|
||||
return snapshot
|
||||
},
|
||||
createReadStream: (options: Parameters<FileHandle['createReadStream']>[0]) => {
|
||||
const stream = handle.createReadStream(options)
|
||||
stream.once('end', () => {
|
||||
state.bytesRead += stream.bytesRead
|
||||
})
|
||||
if (state.readError) {
|
||||
queueMicrotask(() => stream.destroy(new Error('Injected read failure')))
|
||||
}
|
||||
return stream
|
||||
},
|
||||
close: async () => {
|
||||
await handle.close()
|
||||
state.closes++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { appendFile, mkdtemp, rename, rm, truncate, unlink, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
ClaudeTranscriptPreviousCursorMissingError,
|
||||
ClaudeTranscriptTailIncompleteError,
|
||||
replayClaudeTranscriptBranchAncestry,
|
||||
replayClaudeTranscriptBranchAncestryFromJsonl
|
||||
} from './claude-transcript-branch-proof'
|
||||
|
||||
const row = (uuid: string, parentUuid: string | null, extra = {}) =>
|
||||
`${JSON.stringify({ type: 'user', uuid, parentUuid, sessionId: 'provider', ...extra })}\n`
|
||||
const marker = (leafUuid: string, sessionId = 'provider') =>
|
||||
`${JSON.stringify({ type: 'last-prompt', leafUuid, sessionId })}\n`
|
||||
const SUMMARY = `${JSON.stringify({ type: 'summary', summary: 'title' })}\n`
|
||||
const ROOT = row('root', null)
|
||||
const CHILD = row('child', 'root')
|
||||
const SOURCE = ROOT + marker('root')
|
||||
let directory = ''
|
||||
let replayed: string[] = []
|
||||
const replay = (anchorUuid = 'root') =>
|
||||
replayClaudeTranscriptBranchAncestry({
|
||||
transcriptPath: state.path,
|
||||
providerSessionId: 'provider',
|
||||
previousLeafUuid: anchorUuid,
|
||||
ancestryAnchorUuid: anchorUuid,
|
||||
onAncestorRecord: (_record, uuid) => replayed.push(uuid)
|
||||
})
|
||||
const read = async (anchorUuid?: string) => (await replay(anchorUuid)).proof
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-branch-streaming-'))
|
||||
replayed = []
|
||||
Object.assign(state, {
|
||||
path: join(directory, 'transcript.jsonl'),
|
||||
opens: 0,
|
||||
closes: 0,
|
||||
handles: [],
|
||||
bytesRead: 0,
|
||||
statErrorOn: 0,
|
||||
readError: false,
|
||||
afterStat: null
|
||||
})
|
||||
await writeFile(state.path, SOURCE)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
expect(state.closes).toBe(state.opens)
|
||||
for (const handle of state.handles) {
|
||||
// Check the handle itself because descriptor numbers can be reused.
|
||||
expect(handle.fd).toBe(-1)
|
||||
await expect(handle.stat()).rejects.toMatchObject({ code: 'EBADF' })
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('decodes UTF-8 across chunks and replays ancestry beyond large message bodies', async () => {
|
||||
const prefix = JSON.stringify({ type: 'comment', content: '' }).indexOf('"content":"') + 11
|
||||
const comment = `${JSON.stringify({ type: 'comment', content: `${'x'.repeat(65535 - prefix)}🙂é漢字` })}\n`
|
||||
const contents =
|
||||
comment +
|
||||
row('root', null, { message: 'x'.repeat(2 * 1024 * 1024) }) +
|
||||
CHILD +
|
||||
marker('child').trimEnd()
|
||||
await writeFile(state.path, contents)
|
||||
const fromString: string[] = []
|
||||
expect(await replay()).toEqual(
|
||||
replayClaudeTranscriptBranchAncestryFromJsonl({
|
||||
contents,
|
||||
providerSessionId: 'provider',
|
||||
previousLeafUuid: 'root',
|
||||
ancestryAnchorUuid: 'root',
|
||||
onAncestorRecord: (_record, uuid) => fromString.push(uuid)
|
||||
})
|
||||
)
|
||||
expect(replayed).toEqual(fromString)
|
||||
expect(replayed).toEqual(['child'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['malformed middle', `{"broken":\n${SOURCE}`, false],
|
||||
['unterminated malformed tail', `${SOURCE}{"broken":`, true],
|
||||
['terminated malformed tail', `${SOURCE}{"broken":\n`, false]
|
||||
] as const)(
|
||||
'preserves error classification for %s and closes the file',
|
||||
async (_name, contents, incomplete) => {
|
||||
await writeFile(state.path, contents)
|
||||
const result = read()
|
||||
await (incomplete
|
||||
? expect(result).rejects.toBeInstanceOf(ClaudeTranscriptTailIncompleteError)
|
||||
: expect(result).rejects.not.toBeInstanceOf(ClaudeTranscriptTailIncompleteError))
|
||||
}
|
||||
)
|
||||
|
||||
it('proves only the original finite prefix while the same file grows', async () => {
|
||||
state.afterStat = () => appendFile(state.path, CHILD + marker('child') + ' '.repeat(1024 * 1024))
|
||||
expect(await read()).toEqual({ leafUuid: 'root', relation: 'same' })
|
||||
expect(state.bytesRead).toBe(Buffer.byteLength(SOURCE))
|
||||
})
|
||||
|
||||
it('replays the ancestry off the same pinned prefix the proof read', async () => {
|
||||
const contents = ROOT + CHILD
|
||||
await writeFile(state.path, contents)
|
||||
state.afterStat = () => appendFile(state.path, row('grandchild', 'child'))
|
||||
expect(await replay()).toEqual({
|
||||
proof: { leafUuid: 'child', relation: 'descendant' },
|
||||
chain: ['child']
|
||||
})
|
||||
expect(replayed).toEqual(['child'])
|
||||
expect(state.bytesRead).toBe(Buffer.byteLength(contents) * 2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', '', SOURCE, 'root'],
|
||||
['missing previous cursor', SOURCE, CHILD + marker('child'), 'child']
|
||||
] as const)(
|
||||
'finishes a growing %s proof on the same open handle',
|
||||
async (_name, contents, growth, anchor) => {
|
||||
await writeFile(state.path, contents)
|
||||
state.afterStat = () => appendFile(state.path, growth)
|
||||
expect((await read(anchor)).leafUuid).toBe(anchor)
|
||||
expect(state.opens).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['', SUMMARY])('keeps a static missing tip fatal', async (contents) => {
|
||||
await writeFile(state.path, contents)
|
||||
await expect(read()).rejects.toThrow('missing last-prompt marker')
|
||||
})
|
||||
|
||||
it('preserves the typed static missing-cursor error for existing root reproof', async () => {
|
||||
await expect(read('absent')).rejects.toBeInstanceOf(ClaudeTranscriptPreviousCursorMissingError)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['conflict', ROOT + row('root', 'foreign') + marker('root'), 'root', 'conflicting ancestry'],
|
||||
['wrong session', ROOT + marker('root', 'foreign'), 'root', 'invalid last-prompt'],
|
||||
['append order', CHILD + ROOT, 'root', 'parent row follows'],
|
||||
['missing ancestor', CHILD, 'child', 'missing ancestor']
|
||||
] as const)(
|
||||
'does not turn %s into a retry when the file grows',
|
||||
async (_name, contents, anchor, message) => {
|
||||
await writeFile(state.path, contents)
|
||||
state.afterStat = () => appendFile(state.path, CHILD)
|
||||
await expect(read(anchor)).rejects.toThrow(message)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not infer growth from a failed second stat', async () => {
|
||||
await writeFile(state.path, '')
|
||||
state.afterStat = () => appendFile(state.path, SOURCE)
|
||||
state.statErrorOn = 2
|
||||
await expect(read()).rejects.toThrow('missing last-prompt marker')
|
||||
})
|
||||
|
||||
it('keeps reading the original handle after pathname replacement', async () => {
|
||||
state.afterStat = async () => {
|
||||
await rename(state.path, `${state.path}.original`)
|
||||
await writeFile(state.path, ROOT + CHILD + marker('child'))
|
||||
}
|
||||
expect((await read()).leafUuid).toBe('root')
|
||||
})
|
||||
|
||||
it('does not use a replacement file to establish growth', async () => {
|
||||
await writeFile(state.path, '')
|
||||
state.afterStat = async () => {
|
||||
await rename(state.path, `${state.path}.original`)
|
||||
await writeFile(state.path, ROOT + CHILD + marker('child'))
|
||||
}
|
||||
await expect(read()).rejects.toThrow('missing last-prompt marker')
|
||||
})
|
||||
|
||||
it('can finish an opened file after unlink', async () => {
|
||||
state.afterStat = () => unlink(state.path)
|
||||
expect((await read()).leafUuid).toBe('root')
|
||||
})
|
||||
|
||||
it('does not infer growth from truncation', async () => {
|
||||
state.afterStat = () => truncate(state.path, 0)
|
||||
await expect(read()).rejects.toThrow('missing last-prompt marker')
|
||||
})
|
||||
|
||||
it.each(['stat', 'read'] as const)('awaits closure after a %s failure', async (failure) => {
|
||||
state.statErrorOn = failure === 'stat' ? 1 : 0
|
||||
state.readError = failure === 'read'
|
||||
await expect(read()).rejects.toThrow(`Injected ${failure} failure`)
|
||||
})
|
||||
|
||||
it('completes a record appended after the first observed extent without caller retry', async () => {
|
||||
const contents = ROOT + marker('root').slice(0, -5)
|
||||
await writeFile(state.path, contents)
|
||||
state.afterStat = () => appendFile(state.path, marker('root').slice(-5))
|
||||
expect((await read()).leafUuid).toBe('root')
|
||||
expect(state.opens).toBe(1)
|
||||
})
|
||||
|
||||
it('validates the entire refreshed prefix instead of accepting a malformed repair', async () => {
|
||||
const conflicting = row('root', 'foreign')
|
||||
await writeFile(state.path, ROOT + conflicting.slice(0, -5))
|
||||
state.afterStat = () => appendFile(state.path, conflicting.slice(-5))
|
||||
await expect(read()).rejects.toThrow('conflicting ancestry')
|
||||
expect(state.opens).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps an unfinished growing repair retryable after one internal refresh', async () => {
|
||||
const torn = ROOT.slice(0, -5)
|
||||
await writeFile(state.path, torn)
|
||||
state.afterStat = () => appendFile(state.path, ' ')
|
||||
await expect(read()).rejects.toBeInstanceOf(ClaudeTranscriptTailIncompleteError)
|
||||
expect(state.opens).toBe(1)
|
||||
expect(state.bytesRead).toBe(Buffer.byteLength(torn) * 2 + 1)
|
||||
})
|
||||
@@ -1,303 +0,0 @@
|
||||
// Live replay uses the file tail; these marker rules apply when no eligible tail row exists.
|
||||
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ClaudeTranscriptTailIncompleteError,
|
||||
proveClaudeTranscriptBranchFromJsonl
|
||||
} from './claude-transcript-branch-proof'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
async function makeRoot(prefix: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), prefix))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function proveMarkerLeafUuid(
|
||||
transcriptPath: string,
|
||||
providerSessionId: string,
|
||||
previousLeafUuid: string | null = null
|
||||
): Promise<string> {
|
||||
const contents = await readFile(transcriptPath, 'utf8')
|
||||
return proveClaudeTranscriptBranchFromJsonl({ contents, providerSessionId, previousLeafUuid })
|
||||
.leafUuid
|
||||
}
|
||||
|
||||
describe('Claude transcript marker branch proof', () => {
|
||||
it('reads Claude last-prompt leaf metadata as the durable branch marker', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-leaf-')
|
||||
const transcript = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
[
|
||||
{ type: 'user', uuid: 'leaf-old', parentUuid: null, sessionId: 'session-1' },
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'leaf-current',
|
||||
parentUuid: 'leaf-old',
|
||||
sessionId: 'session-1'
|
||||
},
|
||||
{ type: 'last-prompt', leafUuid: 'leaf-current', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1', 'leaf-old')).resolves.toBe(
|
||||
'leaf-current'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when a Claude transcript has no branch marker', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-no-leaf-')
|
||||
const transcript = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
'{"type":"assistant","uuid":"not-a-leaf","parentUuid":null,"sessionId":"session-1"}\n',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow(
|
||||
'missing last-prompt marker'
|
||||
)
|
||||
})
|
||||
|
||||
it('distinguishes an incomplete final Claude JSONL record from durable malformed content', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-torn-tail-')
|
||||
const transcript = join(root, 'session.jsonl')
|
||||
await writeFile(transcript, '{"type":"last-prompt"', 'utf8')
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toBeInstanceOf(
|
||||
ClaudeTranscriptTailIncompleteError
|
||||
)
|
||||
|
||||
await writeFile(transcript, '{"type":"last-prompt"\n', 'utf8')
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.not.toBeInstanceOf(
|
||||
ClaudeTranscriptTailIncompleteError
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a Claude marker on a sibling branch', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-sibling-')
|
||||
const transcript = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
[
|
||||
{ type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' },
|
||||
{ type: 'assistant', uuid: 'expected', parentUuid: 'root', sessionId: 'session-1' },
|
||||
{ type: 'system', uuid: 'sibling', parentUuid: 'root', sessionId: 'session-1' },
|
||||
{ type: 'last-prompt', leafUuid: 'sibling', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1', 'expected')).rejects.toThrow(
|
||||
'sibling branch'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses missing and cyclic Claude parent chains', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-invalid-ancestry-')
|
||||
const missing = join(root, 'missing.jsonl')
|
||||
const cycle = join(root, 'cycle.jsonl')
|
||||
await writeFile(
|
||||
missing,
|
||||
[
|
||||
{ type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' },
|
||||
{ type: 'assistant', uuid: 'leaf', parentUuid: 'absent', sessionId: 'session-1' },
|
||||
{ type: 'last-prompt', leafUuid: 'leaf', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(
|
||||
cycle,
|
||||
[
|
||||
{ type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' },
|
||||
{ type: 'assistant', uuid: 'left', parentUuid: 'right', sessionId: 'session-1' },
|
||||
{ type: 'system', uuid: 'right', parentUuid: 'left', sessionId: 'session-1' },
|
||||
{ type: 'last-prompt', leafUuid: 'right', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(missing, 'session-1', 'expected')).rejects.toThrow(
|
||||
'missing ancestor absent'
|
||||
)
|
||||
await expect(proveMarkerLeafUuid(cycle, 'session-1', 'expected')).rejects.toThrow(
|
||||
'cycle in parentUuid ancestry'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects non-transcript and sidechain UUIDs as the durable leaf', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-leaf-filter-')
|
||||
const transcript = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
[
|
||||
{ type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' },
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'sidechain-assistant',
|
||||
parentUuid: 'main-user',
|
||||
sessionId: 'session-1',
|
||||
isSidechain: true
|
||||
},
|
||||
{ type: 'result', uuid: 'result-frame', parentUuid: 'main-user', sessionId: 'session-1' },
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: 'init-frame',
|
||||
parentUuid: null,
|
||||
sessionId: 'session-1'
|
||||
},
|
||||
{ type: 'stream_event', uuid: 'stream-frame', parentUuid: null, sessionId: 'session-1' },
|
||||
{ type: 'last-prompt', leafUuid: 'sidechain-assistant', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow(
|
||||
'marker leaf is missing from the session graph'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a main leaf whose ancestry crosses a subagent sidechain', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-sidechain-ancestry-')
|
||||
const transcript = join(root, 'session.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
[
|
||||
{ type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' },
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'sidechain-assistant',
|
||||
parentUuid: 'main-user',
|
||||
sessionId: 'session-1',
|
||||
isSidechain: true
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'main-after-sidechain',
|
||||
parentUuid: 'sidechain-assistant',
|
||||
sessionId: 'session-1'
|
||||
},
|
||||
{ type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow(
|
||||
'not on the main transcript'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a main leaf whose ancestry crosses a parent-tool-use sidechain', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-ancestry-')
|
||||
const transcript = join(root, 'transcript.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
[
|
||||
{ type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' },
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'subagent-assistant',
|
||||
parentUuid: 'main-user',
|
||||
sessionId: 'session-1',
|
||||
parent_tool_use_id: 'tool-use-1'
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'main-after-sidechain',
|
||||
parentUuid: 'subagent-assistant',
|
||||
sessionId: 'session-1'
|
||||
},
|
||||
{ type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1')).rejects.toThrow(
|
||||
'not on the main transcript'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a previous cursor descended from a parent-tool-use sidechain', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-parent-tool-cursor-')
|
||||
const transcript = join(root, 'transcript.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
[
|
||||
{ type: 'user', uuid: 'main-user', parentUuid: null, sessionId: 'session-1' },
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'subagent-assistant',
|
||||
parentUuid: 'main-user',
|
||||
sessionId: 'session-1',
|
||||
parent_tool_use_id: 'tool-use-1'
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'main-after-sidechain',
|
||||
parentUuid: 'subagent-assistant',
|
||||
sessionId: 'session-1'
|
||||
},
|
||||
{ type: 'last-prompt', leafUuid: 'main-after-sidechain', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(
|
||||
proveMarkerLeafUuid(transcript, 'session-1', 'main-after-sidechain')
|
||||
).rejects.toThrow('not on the main transcript')
|
||||
})
|
||||
|
||||
it('rejects a post-snapshot descendant whose parent row was observed later', async () => {
|
||||
const root = await makeRoot('orca-native-chat-resolve-claude-post-snapshot-')
|
||||
const transcript = join(root, 'transcript.jsonl')
|
||||
await writeFile(
|
||||
transcript,
|
||||
[
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'descendant',
|
||||
parentUuid: 'previous',
|
||||
sessionId: 'session-1'
|
||||
},
|
||||
{ type: 'assistant', uuid: 'previous', parentUuid: null, sessionId: 'session-1' },
|
||||
{ type: 'last-prompt', leafUuid: 'descendant', sessionId: 'session-1' }
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(proveMarkerLeafUuid(transcript, 'session-1', 'previous')).rejects.toThrow(
|
||||
'parent row follows descendant'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
// The crash boundary: the host wrote a submission row, dispatched, and died
|
||||
// before it learned whether the provider took the message. Replay keeps the message, once, in
|
||||
// doubt: it is neither re-sent nor guessed undelivered.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -17,11 +19,6 @@ import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/str
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import { digestPayload } from './journal-payload-bounds'
|
||||
import {
|
||||
reconcileSubmissions,
|
||||
type ProviderHistoryItem,
|
||||
type ProviderHistoryWindow
|
||||
} from './journal-submission-reconciler'
|
||||
import { createTrackedJournalOpener } from './journal-host-database-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
@@ -68,27 +65,6 @@ export function registerJournalCrashBoundaryTests(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function history(input: {
|
||||
itemId: string
|
||||
clientId: string | null
|
||||
text: string
|
||||
ordinal: number
|
||||
}): ProviderHistoryItem {
|
||||
return {
|
||||
providerItemId: input.itemId,
|
||||
clientMessageId: input.clientId,
|
||||
payloadFingerprint: digestPayload(input.text),
|
||||
identity: { provider: 'codex', threadId: 'thread-1', turnId: TURN_ID, ordinal: input.ordinal }
|
||||
}
|
||||
}
|
||||
|
||||
function window(
|
||||
items: ProviderHistoryItem[],
|
||||
overrides: Partial<ProviderHistoryWindow> = {}
|
||||
): ProviderHistoryWindow {
|
||||
return { items, boundaryConsistent: true, turnInFlight: false, ...overrides }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-crash-'))
|
||||
clock = 1_000
|
||||
@@ -100,7 +76,7 @@ export function registerJournalCrashBoundaryTests(): void {
|
||||
})
|
||||
|
||||
describe('crash between provider accept and journal commit', () => {
|
||||
it('reconciles the echo into the existing bubble instead of duplicating it', async () => {
|
||||
it('keeps a crash-cut send as one bubble in doubt, neither re-sent nor called undelivered', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_1',
|
||||
@@ -108,44 +84,20 @@ export function registerJournalCrashBoundaryTests(): void {
|
||||
body: userMessage('deploy the thing'),
|
||||
fence: 1
|
||||
})
|
||||
// Crash after provider acceptance, before durable dispatch confirmation.
|
||||
// Host dies here: the provider may have taken it, but no dispatch row was written.
|
||||
|
||||
const restarted = await open()
|
||||
expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1'])
|
||||
await restarted.markPendingSubmissionsUnknown(2)
|
||||
expect(restarted.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
expect(restarted.submissions()[0]?.recovered).toBe(true)
|
||||
|
||||
const [outcome] = reconcileSubmissions({
|
||||
submissions: restarted.submissions(),
|
||||
history: window([
|
||||
history({ itemId: 'item-1', clientId: 'cm_1', text: 'deploy the thing', ordinal: 0 })
|
||||
])
|
||||
})
|
||||
expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' })
|
||||
|
||||
if (outcome?.outcome !== 'accepted') {
|
||||
throw new Error('expected the echoed submission to reconcile as accepted')
|
||||
}
|
||||
await restarted.resolveDispatch({
|
||||
clientMessageId: 'cm_1',
|
||||
state: 'accepted',
|
||||
providerIdentity: outcome.identity,
|
||||
fence: 2,
|
||||
expect(restarted.submissions()[0]).toMatchObject({
|
||||
dispatchState: 'unknown',
|
||||
recovered: true
|
||||
})
|
||||
// Provider echoes must reuse the reconciled identity and existing message bubble.
|
||||
await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), {
|
||||
fence: 2,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
|
||||
const items = restarted.snapshot().items
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1'))
|
||||
expect(restarted.receiptFor('cm_1')?.providerItemId).toBe(
|
||||
agentJournalItemKey(outcome.identity)
|
||||
)
|
||||
// Outlived by its writer, so no reader reports it as still working.
|
||||
expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false)
|
||||
expect(restarted.snapshot().items.map((item) => item.itemId)).toEqual([
|
||||
agentJournalSubmissionKey('cm_1')
|
||||
])
|
||||
})
|
||||
|
||||
it('retires an ack timeout on restart without changing its delivery verdict', async () => {
|
||||
@@ -229,42 +181,7 @@ export function registerJournalCrashBoundaryTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a rejected submission as never delivered, and never re-sends it', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_1',
|
||||
payloadFingerprint: digestPayload('never landed'),
|
||||
body: userMessage('never landed'),
|
||||
fence: 1
|
||||
})
|
||||
|
||||
const restarted = await open()
|
||||
await restarted.markPendingSubmissionsUnknown(2)
|
||||
const [outcome] = reconcileSubmissions({
|
||||
submissions: restarted.submissions(),
|
||||
history: window([])
|
||||
})
|
||||
expect(outcome).toEqual({
|
||||
clientMessageId: 'cm_1',
|
||||
outcome: 'rejected',
|
||||
reason: 'not_delivered'
|
||||
})
|
||||
|
||||
await restarted.resolveDispatch({
|
||||
clientMessageId: 'cm_1',
|
||||
state: 'rejected',
|
||||
...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), {
|
||||
surface: 'rejection'
|
||||
}),
|
||||
fence: 2,
|
||||
recovered: true
|
||||
})
|
||||
expect(restarted.snapshot().items).toHaveLength(1)
|
||||
expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected')
|
||||
expect(restarted.receiptFor('cm_1')).toBeNull()
|
||||
})
|
||||
|
||||
it('survives replay of an already-reconciled journal without changing the answer', async () => {
|
||||
it('survives replay of an already-settled journal without changing the answer', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_1',
|
||||
@@ -283,9 +200,6 @@ export function registerJournalCrashBoundaryTests(): void {
|
||||
const reopened = await open()
|
||||
expect(reopened.snapshot()).toEqual(settled)
|
||||
expect(reopened.pendingSubmissions()).toHaveLength(0)
|
||||
expect(
|
||||
reconcileSubmissions({ submissions: reopened.submissions(), history: window([]) })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the receipt across a reopen', async () => {
|
||||
@@ -310,179 +224,4 @@ export function registerJournalCrashBoundaryTests(): void {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconciliation matching', () => {
|
||||
const submissions = [
|
||||
{
|
||||
clientMessageId: 'cm_1',
|
||||
fence: 1,
|
||||
payloadFingerprint: digestPayload('same text'),
|
||||
dispatchState: 'unknown' as const,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null
|
||||
},
|
||||
{
|
||||
clientMessageId: 'cm_2',
|
||||
fence: 1,
|
||||
payloadFingerprint: digestPayload('same text'),
|
||||
dispatchState: 'unknown' as const,
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 2,
|
||||
resolvedAt: null
|
||||
}
|
||||
]
|
||||
|
||||
it('matches each submission to its own echo when the provider carries client ids', () => {
|
||||
const outcomes = reconcileSubmissions({
|
||||
submissions,
|
||||
history: window([
|
||||
history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 }),
|
||||
history({ itemId: 'item-3', clientId: 'cm_2', text: 'same text', ordinal: 2 })
|
||||
])
|
||||
})
|
||||
expect(outcomes).toEqual([
|
||||
expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-1' }),
|
||||
expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-3' })
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses to guess between two identical payloads with no id to tell them apart', () => {
|
||||
const outcomes = reconcileSubmissions({
|
||||
submissions,
|
||||
history: window([
|
||||
history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }),
|
||||
history({ itemId: 'item-3', clientId: null, text: 'same text', ordinal: 2 })
|
||||
])
|
||||
})
|
||||
expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown'])
|
||||
expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' })
|
||||
})
|
||||
|
||||
it('does not assign one matching item to the first of two identical sends', () => {
|
||||
const outcomes = reconcileSubmissions({
|
||||
submissions,
|
||||
history: window([
|
||||
history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 })
|
||||
])
|
||||
})
|
||||
|
||||
expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown'])
|
||||
expect(outcomes.map((outcome) => ('reason' in outcome ? outcome.reason : null))).toEqual([
|
||||
'ambiguous_match',
|
||||
'ambiguous_match'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => {
|
||||
const [outcome] = reconcileSubmissions({
|
||||
submissions: [submissions[0]!],
|
||||
history: window([
|
||||
history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }),
|
||||
history({ itemId: 'item-2', clientId: null, text: 'something else', ordinal: 1 })
|
||||
])
|
||||
})
|
||||
expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' })
|
||||
})
|
||||
|
||||
it('never matches on text alone when the fingerprint disagrees', () => {
|
||||
const [outcome] = reconcileSubmissions({
|
||||
submissions: [submissions[0]!],
|
||||
history: window([
|
||||
{
|
||||
providerItemId: 'item-1',
|
||||
clientMessageId: null,
|
||||
payloadFingerprint: digestPayload('different payload, same rendered text'),
|
||||
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }
|
||||
}
|
||||
])
|
||||
})
|
||||
expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' })
|
||||
})
|
||||
|
||||
it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => {
|
||||
const outcomes = reconcileSubmissions({
|
||||
submissions,
|
||||
history: window([
|
||||
history({ itemId: 'item-1', clientId: 'cm_2', text: 'same text', ordinal: 0 })
|
||||
])
|
||||
})
|
||||
expect(outcomes).toEqual([
|
||||
expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }),
|
||||
expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' })
|
||||
])
|
||||
})
|
||||
|
||||
it('re-matches a submission on the journal key it already adopted, not the raw provider id', () => {
|
||||
const [outcome] = reconcileSubmissions({
|
||||
submissions: [
|
||||
{ ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }
|
||||
],
|
||||
history: window([
|
||||
// Raw provider IDs may change while identity-derived keys remain stable.
|
||||
history({ itemId: 'item-7', clientId: null, text: 'unrelated', ordinal: 0 })
|
||||
])
|
||||
})
|
||||
expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-7' })
|
||||
})
|
||||
|
||||
it('never hands one provider item to two submissions', () => {
|
||||
const outcomes = reconcileSubmissions({
|
||||
submissions: [
|
||||
{ ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) },
|
||||
submissions[1]!
|
||||
],
|
||||
// Two passes claiming one provider item must not create duplicate message bubbles.
|
||||
history: window([
|
||||
history({ itemId: 'item-7', clientId: 'cm_2', text: 'same text', ordinal: 0 })
|
||||
])
|
||||
})
|
||||
expect(outcomes).toEqual([
|
||||
expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }),
|
||||
expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' })
|
||||
])
|
||||
})
|
||||
|
||||
it('stays unknown when the history boundary cannot be trusted', () => {
|
||||
const [outcome] = reconcileSubmissions({
|
||||
submissions: [submissions[0]!],
|
||||
history: window([], { boundaryConsistent: false })
|
||||
})
|
||||
expect(outcome).toEqual({
|
||||
clientMessageId: 'cm_1',
|
||||
outcome: 'unknown',
|
||||
reason: 'history_boundary_inconsistent'
|
||||
})
|
||||
})
|
||||
|
||||
it('stays unknown while a turn is still running', () => {
|
||||
const [outcome] = reconcileSubmissions({
|
||||
submissions: [submissions[0]!],
|
||||
history: window([], { turnInFlight: true })
|
||||
})
|
||||
expect(outcome).toEqual({
|
||||
clientMessageId: 'cm_1',
|
||||
outcome: 'unknown',
|
||||
reason: 'turn_in_flight'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves settled submissions alone', () => {
|
||||
expect(
|
||||
reconcileSubmissions({
|
||||
submissions: [
|
||||
{
|
||||
...submissions[0]!,
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY)
|
||||
},
|
||||
{ ...submissions[1]!, dispatchState: 'rejected' }
|
||||
],
|
||||
history: window([])
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { registerJournalCrashBoundaryTests } from './journal-crash-boundary.spec
|
||||
import { registerJournalEpochReplacementTests } from './journal-epoch-replacement.spec'
|
||||
import { registerJournalItemProvenanceTests } from './journal-item-provenance.spec'
|
||||
import { registerJournalLifecycleBatchAppenderTests } from './journal-lifecycle-batch-appender.spec'
|
||||
import { registerJournalRestartReconciliationTests } from './journal-restart-reconciliation.spec'
|
||||
import { registerJournalUnknownContentTests } from './journal-unknown-content.spec'
|
||||
import { registerJournalUnknownRowKindTests } from './journal-unknown-row-kind.spec'
|
||||
import { registerJournalStopAndResumeRowsTests } from './journal-stop-and-resume-rows.spec'
|
||||
@@ -16,7 +15,6 @@ describe('journal-crash-boundary', registerJournalCrashBoundaryTests)
|
||||
describe('journal-epoch-replacement', registerJournalEpochReplacementTests)
|
||||
describe('journal-item-provenance', registerJournalItemProvenanceTests)
|
||||
describe('journal-lifecycle-batch-appender', registerJournalLifecycleBatchAppenderTests)
|
||||
describe('journal-restart-reconciliation', registerJournalRestartReconciliationTests)
|
||||
describe('journal-unknown-content', registerJournalUnknownContentTests)
|
||||
describe('journal-unknown-row-kind', registerJournalUnknownRowKindTests)
|
||||
describe('journal-stop-and-resume-rows', registerJournalStopAndResumeRowsTests)
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
|
||||
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 { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { digestPayload } from './journal-payload-bounds'
|
||||
import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation'
|
||||
import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler'
|
||||
import { createTrackedJournalOpener } from './journal-host-database-test-support'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
export function registerJournalRestartReconciliationTests(): void {
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: claudeProviderHandle('provider-1', null)
|
||||
}
|
||||
|
||||
function claudeIdentity(uuid: string): AgentJournalItemIdentity {
|
||||
return { provider: 'claude', sessionId: 'provider-1', uuid }
|
||||
}
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
|
||||
function tick(): number {
|
||||
clock += 1
|
||||
return clock
|
||||
}
|
||||
|
||||
function userMessage(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
async function open() {
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
stateDirectory: root,
|
||||
now: tick,
|
||||
mintEpoch: () => `epoch-${clock}`
|
||||
})
|
||||
}
|
||||
|
||||
function history(uuid: string, text: string): ProviderHistoryItem {
|
||||
return {
|
||||
providerItemId: uuid,
|
||||
clientMessageId: null,
|
||||
payloadFingerprint: digestPayload(text),
|
||||
identity: claudeIdentity(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
function window(
|
||||
items: ProviderHistoryItem[],
|
||||
overrides: Partial<ProviderHistoryWindow> = {}
|
||||
): ProviderHistoryWindow {
|
||||
return { items, boundaryConsistent: true, turnInFlight: false, ...overrides }
|
||||
}
|
||||
|
||||
async function reopenAfterCrash(
|
||||
body: AgentJournalMessageItem = userMessage('deploy the thing'),
|
||||
text = 'deploy the thing'
|
||||
) {
|
||||
const journal = await open()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_1',
|
||||
payloadFingerprint: digestPayload(text),
|
||||
body,
|
||||
fence: 1
|
||||
})
|
||||
const restarted = await open()
|
||||
await restarted.markPendingSubmissionsUnknown(2)
|
||||
return restarted
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-restart-reconcile-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('reconcileJournalSubmissionsAgainstHistory', () => {
|
||||
it('settles a message found in provider history as accepted on its provider identity', async () => {
|
||||
const journal = await reopenAfterCrash()
|
||||
|
||||
const settled = await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal,
|
||||
fence: 2,
|
||||
history: window([history('uuid-1', 'deploy the thing')])
|
||||
})
|
||||
|
||||
expect(settled).toEqual(['cm_1'])
|
||||
const submission = journal.submissions()[0]
|
||||
expect(submission?.dispatchState).toBe('accepted')
|
||||
expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1')))
|
||||
})
|
||||
|
||||
it('settles a message provably absent from history as rejected: not_delivered', async () => {
|
||||
const journal = await reopenAfterCrash()
|
||||
|
||||
const settled = await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal,
|
||||
fence: 2,
|
||||
history: window([])
|
||||
})
|
||||
|
||||
expect(settled).toEqual(['cm_1'])
|
||||
const submission = journal.submissions()[0]
|
||||
expect(submission?.dispatchState).toBe('rejected')
|
||||
// Released clients display reason text directly; preserve wording beside typed facts.
|
||||
expect(submission?.reason).toBe(
|
||||
agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' })
|
||||
.reason
|
||||
)
|
||||
expect(submission?.rejection).toEqual({ kind: 'notDelivered' })
|
||||
expect(submission && classifyDispatchRejection(submission)).toEqual({
|
||||
category: 'undelivered',
|
||||
verdict: null,
|
||||
kind: 'notDelivered'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a submission unknown while the provider reports a turn in flight', async () => {
|
||||
const journal = await reopenAfterCrash()
|
||||
|
||||
const settled = await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal,
|
||||
fence: 2,
|
||||
history: window([], { turnInFlight: true })
|
||||
})
|
||||
|
||||
expect(settled).toEqual([])
|
||||
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
})
|
||||
|
||||
it('leaves a submission unknown when the history boundary is inconsistent', async () => {
|
||||
const journal = await reopenAfterCrash()
|
||||
|
||||
const settled = await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal,
|
||||
fence: 2,
|
||||
history: window([], { boundaryConsistent: false })
|
||||
})
|
||||
|
||||
expect(settled).toEqual([])
|
||||
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
})
|
||||
|
||||
it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => {
|
||||
const journal = await reopenAfterCrash(
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{ type: 'image-ref', path: '/tmp/shot.png' }
|
||||
]
|
||||
},
|
||||
'look at this'
|
||||
)
|
||||
|
||||
const settled = await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal,
|
||||
fence: 2,
|
||||
history: window([])
|
||||
})
|
||||
|
||||
expect(settled).toEqual([])
|
||||
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
})
|
||||
|
||||
it('leaves a send that carried stopped history unknown: its provider copy leads with that history', async () => {
|
||||
const journal = await open()
|
||||
for (const clientMessageId of ['stopped', 'cm_1']) {
|
||||
await journal.appendSubmission({
|
||||
clientMessageId,
|
||||
payloadFingerprint: digestPayload(clientMessageId),
|
||||
body: userMessage(clientMessageId),
|
||||
fence: 1,
|
||||
handoverRecorded: true
|
||||
})
|
||||
}
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'cm_1',
|
||||
state: 'pending',
|
||||
fence: 1,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE,
|
||||
carries: ['stopped']
|
||||
})
|
||||
const restarted = await open()
|
||||
await restarted.markPendingSubmissionsUnknown(2)
|
||||
|
||||
const settled = await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal: restarted,
|
||||
fence: 2,
|
||||
history: window([])
|
||||
})
|
||||
|
||||
expect(settled).toEqual([])
|
||||
expect(restarted.submission('cm_1')?.dispatchState).toBe('unknown')
|
||||
expect(restarted.stoppedHistoryCarried().get('cm_1')).toEqual(['stopped'])
|
||||
})
|
||||
|
||||
it('leaves multi-block text sends unknown because Claude joins them before recording history', async () => {
|
||||
const journal = await reopenAfterCrash(
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: 'second' }
|
||||
]
|
||||
},
|
||||
'first\nsecond'
|
||||
)
|
||||
|
||||
const settled = await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal,
|
||||
fence: 2,
|
||||
history: window([history('uuid-1', 'first\nsecond')])
|
||||
})
|
||||
|
||||
expect(settled).toEqual([])
|
||||
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
})
|
||||
|
||||
it('does not let an item the journal already committed stand in for a new send', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), {
|
||||
fence: 1,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_1',
|
||||
payloadFingerprint: digestPayload('deploy the thing'),
|
||||
body: userMessage('deploy the thing'),
|
||||
fence: 1
|
||||
})
|
||||
const restarted = await open()
|
||||
await restarted.markPendingSubmissionsUnknown(2)
|
||||
|
||||
await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal: restarted,
|
||||
fence: 2,
|
||||
history: window([history('uuid-old', 'deploy the thing')])
|
||||
})
|
||||
|
||||
expect(restarted.submissions()[0]?.dispatchState).toBe('rejected')
|
||||
})
|
||||
|
||||
it('does not let an older accepted provider item stand in for a new identical send', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_old',
|
||||
payloadFingerprint: digestPayload('deploy the thing'),
|
||||
body: userMessage('deploy the thing'),
|
||||
fence: 1
|
||||
})
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'cm_old',
|
||||
state: 'accepted',
|
||||
providerIdentity: claudeIdentity('uuid-old'),
|
||||
fence: 1
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_new',
|
||||
payloadFingerprint: digestPayload('deploy the thing'),
|
||||
body: userMessage('deploy the thing'),
|
||||
fence: 1
|
||||
})
|
||||
const restarted = await open()
|
||||
await restarted.markPendingSubmissionsUnknown(2)
|
||||
|
||||
await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal: restarted,
|
||||
fence: 2,
|
||||
history: window([history('uuid-old', 'deploy the thing')])
|
||||
})
|
||||
|
||||
expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([
|
||||
'accepted',
|
||||
'rejected'
|
||||
])
|
||||
expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' })
|
||||
})
|
||||
|
||||
it('leaves two identical unsettled sends unknown rather than guessing between them', async () => {
|
||||
const journal = await open()
|
||||
for (const id of ['cm_1', 'cm_2']) {
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: id,
|
||||
payloadFingerprint: digestPayload('ping'),
|
||||
body: userMessage('ping'),
|
||||
fence: 1
|
||||
})
|
||||
}
|
||||
const restarted = await open()
|
||||
await restarted.markPendingSubmissionsUnknown(2)
|
||||
|
||||
await reconcileJournalSubmissionsAgainstHistory({
|
||||
journal: restarted,
|
||||
fence: 2,
|
||||
history: window([history('uuid-1', 'ping'), history('uuid-2', 'ping')])
|
||||
})
|
||||
|
||||
expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([
|
||||
'unknown',
|
||||
'unknown'
|
||||
])
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// Provider history may narrow unknown delivery, never trigger automatic redelivery.
|
||||
// User retries rotate IDs; unproven outcomes retain their original uncertainty.
|
||||
|
||||
import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
agentJournalSubmissionKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { reconcileSubmissions, type ProviderHistoryWindow } from './journal-submission-reconciler'
|
||||
|
||||
/** Attachment paths are absent from provider history; only text-only sends can prove non-delivery. */
|
||||
function comparableBody(body: AgentJournalMessageItem | undefined): boolean {
|
||||
return (
|
||||
body?.kind === 'message' &&
|
||||
body.role === 'user' &&
|
||||
// Conversation commands leave no provider user-message item to compare.
|
||||
body.command === undefined &&
|
||||
body.blocks.length === 1 &&
|
||||
body.blocks[0]?.type === 'text' &&
|
||||
body.blocks[0].text.trim().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmission[] {
|
||||
const snapshot = journal.snapshot()
|
||||
const { items, submissions } = agentSessionCurrentContextRows(
|
||||
snapshot.items,
|
||||
snapshot.submissions
|
||||
)
|
||||
const bodies = new Map(items.map((item) => [item.itemId, item.body]))
|
||||
return submissions.filter((submission) => {
|
||||
if (
|
||||
(submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') ||
|
||||
isQueuedAgentJournalSubmission(submission) ||
|
||||
// Missing history cannot prove queued handoffs unsent.
|
||||
submission.queuedMessageId !== undefined ||
|
||||
// Carried stopped-history prefixes change fingerprints; absence cannot prove non-delivery.
|
||||
journal.stoppedHistoryCarried().has(submission.clientMessageId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const body = bodies.get(agentJournalSubmissionKey(submission.clientMessageId))
|
||||
return comparableBody(body?.kind === 'message' ? body : undefined)
|
||||
})
|
||||
}
|
||||
|
||||
/** Exclude committed history so uncertain sends cannot match earlier identical messages. */
|
||||
function unseenHistory(
|
||||
journal: AgentSessionJournal,
|
||||
history: ProviderHistoryWindow
|
||||
): ProviderHistoryWindow {
|
||||
const snapshot = journal.snapshot()
|
||||
const committed = new Set(snapshot.items.map((item) => item.itemId))
|
||||
// Accepted echoes render under Orca IDs; also exclude their aliased provider identities.
|
||||
for (const submission of snapshot.submissions) {
|
||||
if (submission.dispatchState === 'accepted' && submission.providerItemId) {
|
||||
committed.add(submission.providerItemId)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...history,
|
||||
// Identity-free items rely on the adapter's after-last-acceptance history window.
|
||||
items: history.items.filter(
|
||||
(item) => item.identity === undefined || !committed.has(agentJournalItemKey(item.identity))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileJournalSubmissionsAgainstHistory(input: {
|
||||
journal: AgentSessionJournal
|
||||
fence: number
|
||||
history: ProviderHistoryWindow
|
||||
}): Promise<string[]> {
|
||||
const submissions = comparableSubmissions(input.journal)
|
||||
if (submissions.length === 0) {
|
||||
return []
|
||||
}
|
||||
const settled: string[] = []
|
||||
for (const outcome of reconcileSubmissions({
|
||||
submissions,
|
||||
history: unseenHistory(input.journal, input.history)
|
||||
})) {
|
||||
if (outcome.outcome === 'unknown') {
|
||||
console.warn('[journal-reconcile] submission left unconfirmed:', {
|
||||
clientMessageId: outcome.clientMessageId,
|
||||
reason: outcome.reason,
|
||||
fence: input.fence
|
||||
})
|
||||
continue
|
||||
}
|
||||
await input.journal.resolveDispatch(
|
||||
outcome.outcome === 'accepted'
|
||||
? {
|
||||
clientMessageId: outcome.clientMessageId,
|
||||
state: 'accepted',
|
||||
providerIdentity: outcome.identity,
|
||||
fence: input.fence,
|
||||
recovered: true
|
||||
}
|
||||
: {
|
||||
clientMessageId: outcome.clientMessageId,
|
||||
state: 'rejected',
|
||||
...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), {
|
||||
surface: 'rejection'
|
||||
}),
|
||||
fence: input.fence,
|
||||
recovered: true
|
||||
}
|
||||
)
|
||||
settled.push(outcome.clientMessageId)
|
||||
}
|
||||
return settled
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// Crash recovery resolves durable submissions against history without automatic redelivery.
|
||||
// Match echoed IDs, adopted keys, then unique fingerprints; identical sends must never collapse.
|
||||
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalSubmission
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../shared/structured-agent-session-dispatch-rejection'
|
||||
|
||||
export type ProviderHistoryItem = {
|
||||
/** Claim each provider item once; derive journal keys from identity instead of unstable raw IDs. */
|
||||
providerItemId: string
|
||||
/** Null when the provider does not echo client IDs. */
|
||||
clientMessageId: string | null
|
||||
/** Match only a unique unclaimed payload candidate. */
|
||||
payloadFingerprint: string | null
|
||||
/** Absent identity preserves the submission's existing Orca key. */
|
||||
identity?: AgentJournalItemIdentity
|
||||
}
|
||||
|
||||
export type ProviderHistoryWindow = {
|
||||
items: readonly ProviderHistoryItem[]
|
||||
/** Only a complete, consistent history boundary can prove non-delivery by absence. */
|
||||
boundaryConsistent: boolean
|
||||
/** An in-flight turn makes absence inconclusive. */
|
||||
turnInFlight: boolean
|
||||
}
|
||||
|
||||
export type SubmissionReconciliation =
|
||||
| {
|
||||
clientMessageId: string
|
||||
outcome: 'accepted'
|
||||
providerItemId: string
|
||||
identity: AgentJournalItemIdentity
|
||||
}
|
||||
| { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason }
|
||||
| { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason }
|
||||
|
||||
export type SubmissionRejectionReason = typeof DISPATCH_REJECTED_NOT_DELIVERED
|
||||
|
||||
export type SubmissionUnknownReason =
|
||||
| 'history_boundary_inconsistent'
|
||||
| 'turn_in_flight'
|
||||
| 'ambiguous_match'
|
||||
|
||||
/** Run stronger matches across all submissions first; weak matches cannot steal exact-ID candidates. */
|
||||
export function reconcileSubmissions(input: {
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
history: ProviderHistoryWindow
|
||||
}): SubmissionReconciliation[] {
|
||||
const unsettled = input.submissions.filter(
|
||||
(submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'unknown'
|
||||
)
|
||||
const claimed = new Set<string>()
|
||||
const matched = new Map<string, ProviderHistoryItem>()
|
||||
const ambiguous = new Set<string>()
|
||||
|
||||
claimBy(
|
||||
unsettled,
|
||||
input.history.items,
|
||||
claimed,
|
||||
matched,
|
||||
(submission, item) =>
|
||||
// Adopted keys survive provider raw-ID renumbering.
|
||||
Boolean(submission.providerItemId) &&
|
||||
item.identity !== undefined &&
|
||||
submission.providerItemId === agentJournalItemKey(item.identity)
|
||||
)
|
||||
claimBy(
|
||||
unsettled,
|
||||
input.history.items,
|
||||
claimed,
|
||||
matched,
|
||||
(submission, item) =>
|
||||
Boolean(item.clientMessageId) && item.clientMessageId === submission.clientMessageId
|
||||
)
|
||||
|
||||
// Match fingerprint groups together: one candidate cannot distinguish identical submissions.
|
||||
const submissionsByFingerprint = new Map<string, AgentJournalSubmission[]>()
|
||||
for (const submission of unsettled) {
|
||||
if (matched.has(submission.clientMessageId) || !submission.payloadFingerprint) {
|
||||
continue
|
||||
}
|
||||
const sameFingerprint = submissionsByFingerprint.get(submission.payloadFingerprint) ?? []
|
||||
sameFingerprint.push(submission)
|
||||
submissionsByFingerprint.set(submission.payloadFingerprint, sameFingerprint)
|
||||
}
|
||||
for (const [fingerprint, fingerprintSubmissions] of submissionsByFingerprint) {
|
||||
const candidates = input.history.items.filter(
|
||||
(item) => !claimed.has(item.providerItemId) && item.payloadFingerprint === fingerprint
|
||||
)
|
||||
if (fingerprintSubmissions.length === 1 && candidates.length === 1) {
|
||||
const [submission] = fingerprintSubmissions
|
||||
const [only] = candidates
|
||||
claimed.add(only!.providerItemId)
|
||||
matched.set(submission!.clientMessageId, only!)
|
||||
continue
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
// Equal payloads without IDs cannot establish which submission arrived.
|
||||
for (const submission of fingerprintSubmissions) {
|
||||
ambiguous.add(submission.clientMessageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unsettled.map((submission) => resolveOne(submission, matched, ambiguous, input.history))
|
||||
}
|
||||
|
||||
function claimBy(
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
items: readonly ProviderHistoryItem[],
|
||||
claimed: Set<string>,
|
||||
matched: Map<string, ProviderHistoryItem>,
|
||||
matches: (submission: AgentJournalSubmission, item: ProviderHistoryItem) => boolean
|
||||
): void {
|
||||
for (const submission of submissions) {
|
||||
if (matched.has(submission.clientMessageId)) {
|
||||
continue
|
||||
}
|
||||
const item = items.find((candidate) => {
|
||||
return !claimed.has(candidate.providerItemId) && matches(submission, candidate)
|
||||
})
|
||||
if (item) {
|
||||
claimed.add(item.providerItemId)
|
||||
matched.set(submission.clientMessageId, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOne(
|
||||
submission: AgentJournalSubmission,
|
||||
matched: Map<string, ProviderHistoryItem>,
|
||||
ambiguous: Set<string>,
|
||||
history: ProviderHistoryWindow
|
||||
): SubmissionReconciliation {
|
||||
const item = matched.get(submission.clientMessageId)
|
||||
if (item) {
|
||||
return {
|
||||
clientMessageId: submission.clientMessageId,
|
||||
outcome: 'accepted',
|
||||
providerItemId: item.providerItemId,
|
||||
identity: item.identity ?? { provider: 'orca', clientMessageId: submission.clientMessageId }
|
||||
}
|
||||
}
|
||||
if (ambiguous.has(submission.clientMessageId)) {
|
||||
return {
|
||||
clientMessageId: submission.clientMessageId,
|
||||
outcome: 'unknown',
|
||||
reason: 'ambiguous_match'
|
||||
}
|
||||
}
|
||||
if (!history.boundaryConsistent) {
|
||||
return {
|
||||
clientMessageId: submission.clientMessageId,
|
||||
outcome: 'unknown',
|
||||
reason: 'history_boundary_inconsistent'
|
||||
}
|
||||
}
|
||||
if (history.turnInFlight) {
|
||||
return {
|
||||
clientMessageId: submission.clientMessageId,
|
||||
outcome: 'unknown',
|
||||
reason: 'turn_in_flight'
|
||||
}
|
||||
}
|
||||
return {
|
||||
clientMessageId: submission.clientMessageId,
|
||||
outcome: 'rejected',
|
||||
reason: DISPATCH_REJECTED_NOT_DELIVERED
|
||||
}
|
||||
}
|
||||
-4
@@ -568,8 +568,6 @@ describe('what an earlier host process left behind', () => {
|
||||
})
|
||||
|
||||
it('leaves a legacy pending message and a handed-over one in doubt, never re-sent (W4′c)', async () => {
|
||||
const providerHistoryWindow = vi.fn(async () => null)
|
||||
adapterExtras = { providerHistoryWindow }
|
||||
await writeAsEarlierProcess(async (journal, fence) => {
|
||||
await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence })
|
||||
await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence })
|
||||
@@ -587,8 +585,6 @@ describe('what an earlier host process left behind', () => {
|
||||
|
||||
expect(await submission('legacy')).toMatchObject({ dispatchState: 'unknown', recovered: true })
|
||||
expect(await submission('handed')).toMatchObject({ dispatchState: 'unknown', recovered: true })
|
||||
// History reconciliation requires a won lease.
|
||||
expect(providerHistoryWindow).not.toHaveBeenCalled()
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+57
-1
@@ -2,7 +2,17 @@ import { expect, it, vi } from 'vitest'
|
||||
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { HOST_TEST_NOW, HOST_TEST_THREAD } from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION,
|
||||
HOST_TEST_THREAD,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { journalIdentityFor } from './structured-agent-session-attach'
|
||||
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { digestPayload } from '../agent-session-journal/journal-payload-bounds'
|
||||
import {
|
||||
CALLER,
|
||||
attach,
|
||||
@@ -45,3 +55,49 @@ it("creates a chat while another saved chat's owner check fails", async () => {
|
||||
expect(store.getRecord(peerId)?.lease.unreconciled).toBe(true)
|
||||
expect(store.getRecord('new-chat')?.lease.claimStatus).toBe('live')
|
||||
})
|
||||
|
||||
it('keeps a send a crash cut off as sent when its chat reattaches', async () => {
|
||||
const record = await attach()
|
||||
const prior = hostTestState()
|
||||
if (!record) {
|
||||
throw new Error('the initial attach did not reserve its chat')
|
||||
}
|
||||
await prior.host.close(HOST_TEST_SESSION, 'evict')
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: journalIdentityFor(record, attachParams()),
|
||||
database: openTestJournalHostDatabase(prior.root)
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'crashed-send',
|
||||
payloadFingerprint: digestPayload('deploy the thing'),
|
||||
body: hostTestMessage('deploy the thing'),
|
||||
fence: record.lease.runtimeFence
|
||||
})
|
||||
await journal.close()
|
||||
|
||||
const store = await openTestAgentSessionRecordStore(prior.root)
|
||||
await store.reconcileOnRestart({
|
||||
probe: async () => ({ outcome: 'pid-absent' }),
|
||||
now: HOST_TEST_NOW + 1
|
||||
})
|
||||
const host = new StructuredAgentSessionHost({ ...prior.host.deps, store })
|
||||
replaceHostTestState({ store, host })
|
||||
|
||||
expect(
|
||||
await host.attach(
|
||||
CALLER,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? null)
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
unconfirmedClientMessageIds: ['crashed-send'],
|
||||
page: {
|
||||
submissions: [
|
||||
{ clientMessageId: 'crashed-send', dispatchState: 'unknown', recovered: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(prior.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands'
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionAccountHome,
|
||||
AgentSessionExecutionLocation
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract'
|
||||
import type {
|
||||
@@ -171,13 +168,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
readOptionRestoreFailures = (sessionId: string): readonly string[] =>
|
||||
this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? []
|
||||
|
||||
providerHistoryWindow = (input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
accountHome: AgentSessionAccountHome
|
||||
}) =>
|
||||
this.requireAgent(input.identity).adapter.providerHistoryWindow?.(input) ??
|
||||
Promise.resolve(null)
|
||||
|
||||
copyConversation: NonNullable<StructuredAgentSessionAdapter['copyConversation']> = (input) => {
|
||||
const { adapter } = this.requireAgent(input.identity)
|
||||
if (!adapter.copyConversation) {
|
||||
|
||||
@@ -33,7 +33,6 @@ export type {
|
||||
import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words'
|
||||
import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options'
|
||||
import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
import type {
|
||||
StructuredAgentSessionStartupAttempt,
|
||||
StructuredAgentSessionAcquisitionReceipt
|
||||
@@ -270,11 +269,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
|
||||
fence: number
|
||||
}): Promise<StructuredAgentSessionLiveOptions>
|
||||
readOptionRestoreFailures?(sessionId: string): readonly string[]
|
||||
/** Only consistent history boundaries can prove non-delivery; omitted windows leave unsettled sends unknown. */
|
||||
providerHistoryWindow?(input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
accountHome: AgentSessionAccountHome
|
||||
}): Promise<ProviderHistoryWindow | null>
|
||||
/** Copy the provider conversation without changing the chat’s ID; omit for CLI-based forks. */
|
||||
copyConversation?(input: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
admitAttachOrRefuse,
|
||||
attachJournal,
|
||||
classifyStoreFailure,
|
||||
journalIdentityFor,
|
||||
reserveRequestFor,
|
||||
type AgentSessionAttachAuthority,
|
||||
type AgentSessionAttachParams,
|
||||
@@ -47,8 +46,6 @@ import {
|
||||
withAgentSessionCreatePhase,
|
||||
type AgentSessionCreatePhaseRecorder
|
||||
} from '../../observability/agent-session-instrumentation'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
import { readProviderHistoryWindow } from './structured-agent-session-provider-history-window'
|
||||
import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract'
|
||||
import type { StructuredAgentSessionStartupProgress } from './structured-agent-session-startup-attempt'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
@@ -141,7 +138,6 @@ export async function performAttach(
|
||||
let reservedRecord: AgentSessionRecord | null = null
|
||||
let unsupportedReservationSettlementAttempted = false
|
||||
let replayed = false
|
||||
let providerHistoryWindow: ProviderHistoryWindow | null = null
|
||||
const lock = input.lock ?? heldStructuredAgentSessionAttachLock(input.acquireSignal)
|
||||
const preparedTranscript = store.getRecord(sessionId)
|
||||
? { ok: true as const, items: null }
|
||||
@@ -194,15 +190,6 @@ export async function performAttach(
|
||||
return { ok: false, refusal: replay.refusal }
|
||||
}
|
||||
}
|
||||
// Read history before spawn; a live child makes absent prompts inconclusive for non-delivery.
|
||||
providerHistoryWindow = await lock.wait(
|
||||
readProviderHistoryWindow({
|
||||
adapter: input.adapter,
|
||||
identity: journalIdentityFor(record, params),
|
||||
accountHome: record.accountHome,
|
||||
ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease)
|
||||
})
|
||||
)
|
||||
if (agentSessionLeaseAdmitsWriter(record.lease)) {
|
||||
// Keep the existing live owner.
|
||||
} else if (replayed && reserved.operationRow.outcome.status === 'succeeded') {
|
||||
@@ -269,10 +256,7 @@ export async function performAttach(
|
||||
await input.beforeJournalOpen?.()
|
||||
const journal = await attachJournal({
|
||||
record: acquired,
|
||||
params,
|
||||
adapter: input.adapter,
|
||||
openConversation: input.openConversation,
|
||||
providerHistoryWindow
|
||||
openConversation: input.openConversation
|
||||
})
|
||||
await importAdoptedTranscript(params, journal, acquired, preparedTranscript.items)
|
||||
await input.onAttached(journal, acquisitionGeneration, owner)
|
||||
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
// Verify attach wires history reconciliation without redelivery.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection'
|
||||
import { digestPayload } from '../agent-session-journal/journal-payload-bounds'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
import {
|
||||
createTrackedJournalOpener,
|
||||
openTestJournalHostDatabase
|
||||
} from '../agent-session-journal/journal-host-database-test-support'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation'
|
||||
import {
|
||||
attachJournal,
|
||||
journalIdentityFor,
|
||||
type AgentSessionAttachParams
|
||||
} from './structured-agent-session-attach'
|
||||
|
||||
const RECORD = agentSessionRecordFixture()
|
||||
|
||||
const PARAMS = {
|
||||
envelope: {
|
||||
sessionId: RECORD.sessionId,
|
||||
clientOperationId: 'op-1',
|
||||
expectedRuntimeFence: RECORD.lease.runtimeFence,
|
||||
payloadFingerprint: 'fp'
|
||||
},
|
||||
location: RECORD.location,
|
||||
provider: 'claude',
|
||||
agent: 'claude',
|
||||
accountHome: RECORD.accountHome,
|
||||
runtimeKind: 'native'
|
||||
} as unknown as AgentSessionAttachParams
|
||||
|
||||
const IDENTITY = journalIdentityFor(RECORD, PARAMS)
|
||||
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function userMessage(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
function window(overrides: Partial<ProviderHistoryWindow> = {}): ProviderHistoryWindow {
|
||||
return { items: [], boundaryConsistent: true, turnInFlight: false, ...overrides }
|
||||
}
|
||||
|
||||
/** Spy on every send route so reconciliation cannot silently redeliver. */
|
||||
function adapterWith(providerHistoryWindow?: () => Promise<ProviderHistoryWindow | null>): {
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
dispatch: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const dispatch = vi.fn()
|
||||
const adapter = {
|
||||
dispatch,
|
||||
...(providerHistoryWindow ? { providerHistoryWindow } : {})
|
||||
} as unknown as StructuredAgentSessionAdapter
|
||||
return { adapter, dispatch }
|
||||
}
|
||||
|
||||
async function crashedJournal(clientMessageId = 'cm_1', text = 'deploy the thing') {
|
||||
const journal = await journals.open({
|
||||
identity: IDENTITY,
|
||||
stateDirectory: root
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId,
|
||||
payloadFingerprint: digestPayload(text),
|
||||
body: userMessage(text),
|
||||
fence: RECORD.lease.runtimeFence
|
||||
})
|
||||
await journal.close()
|
||||
}
|
||||
|
||||
async function attach(adapter: StructuredAgentSessionAdapter) {
|
||||
const attached = await attachJournal({
|
||||
record: RECORD,
|
||||
params: PARAMS,
|
||||
openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)),
|
||||
adapter
|
||||
})
|
||||
journals.track(attached.journal)
|
||||
return attached
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-attach-reconcile-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('attachJournal restart reconciliation', () => {
|
||||
it('settles a provably undelivered submission and stops reporting it unconfirmed', async () => {
|
||||
await crashedJournal()
|
||||
const { adapter, dispatch } = adapterWith(async () => window())
|
||||
|
||||
const attached = await attach(adapter)
|
||||
|
||||
expect(attached.unconfirmedClientMessageIds).toEqual([])
|
||||
const submission = attached.journal.submissions()[0]
|
||||
expect(submission?.dispatchState).toBe('rejected')
|
||||
expect(submission?.rejection).toEqual({ kind: 'notDelivered' })
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives a send the provider never received no verdict and no listing', async () => {
|
||||
await crashedJournal()
|
||||
const { adapter } = adapterWith(async () => window())
|
||||
|
||||
const attached = await attach(adapter)
|
||||
|
||||
// A crash-stranded send is unconfirmed, not a failure.
|
||||
const { items, submissions } = attached.journal.snapshot()
|
||||
expect(
|
||||
projectStructuredAgentSessionStatusState(items, submissions, RECORD.lease.runtimeFence)
|
||||
).toMatchObject({ summary: { status: null }, latestRequest: null })
|
||||
})
|
||||
|
||||
it('still reports a submission unconfirmed when the window cannot decide it', async () => {
|
||||
await crashedJournal()
|
||||
const { adapter, dispatch } = adapterWith(async () => window({ turnInFlight: true }))
|
||||
|
||||
const attached = await attach(adapter)
|
||||
|
||||
expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1'])
|
||||
expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the crash boundary untouched for an adapter that reports no history', async () => {
|
||||
await crashedJournal()
|
||||
const { adapter } = adapterWith()
|
||||
|
||||
const attached = await attach(adapter)
|
||||
|
||||
expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1'])
|
||||
expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
})
|
||||
|
||||
it('does not fail the attach when reading provider history throws', async () => {
|
||||
await crashedJournal()
|
||||
const { adapter } = adapterWith(async () => {
|
||||
throw new Error('transcript unreadable')
|
||||
})
|
||||
|
||||
const attached = await attach(adapter)
|
||||
|
||||
expect(attached.unconfirmedClientMessageIds).toEqual(['cm_1'])
|
||||
expect(attached.journal.submissions()[0]?.dispatchState).toBe('unknown')
|
||||
})
|
||||
|
||||
it('leaves a message the open conversation still has queued alone (W4′e)', async () => {
|
||||
const journal = await journals.open({
|
||||
identity: IDENTITY,
|
||||
stateDirectory: root
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'queued',
|
||||
payloadFingerprint: digestPayload('still queued'),
|
||||
body: userMessage('still queued'),
|
||||
fence: RECORD.lease.runtimeFence,
|
||||
handoverRecorded: true
|
||||
})
|
||||
// Empty consistent history can prove handed-over sends undelivered.
|
||||
const { adapter, dispatch } = adapterWith(async () => window())
|
||||
|
||||
const attached = await attachJournal({
|
||||
record: RECORD,
|
||||
params: PARAMS,
|
||||
adapter,
|
||||
openConversation: async () => journal
|
||||
})
|
||||
|
||||
expect(attached.journal).toBe(journal)
|
||||
expect(journal.submissions()[0]).toMatchObject({ dispatchState: 'pending' })
|
||||
expect(journal.submissions()[0]?.handedOverAt).toBeUndefined()
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -36,9 +36,6 @@ import {
|
||||
} from '../../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { reconcileJournalSubmissionsAgainstHistory } from '../agent-session-journal/journal-restart-reconciliation'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message'
|
||||
|
||||
/** Clients cannot assert spawn tokens, claim keys or owner death; only host observations grant authority. */
|
||||
@@ -152,79 +149,38 @@ export function journalIdentityFor(
|
||||
|
||||
export type AttachedJournal = {
|
||||
journal: AgentSessionJournal
|
||||
/** Submissions the crash boundary left `unknown`; kept on the wire for older peers. */
|
||||
unconfirmedClientMessageIds: string[]
|
||||
}
|
||||
|
||||
/** Reconcile pre-spawn history under the won lease; never resend or touch unhanded queues. */
|
||||
/**
|
||||
* The conversation's journal, opened — and its crash boundary settled — by the conversation's own
|
||||
* open. A send the boundary could only doubt stays `unknown`: whether the provider took it is never
|
||||
* inferred from provider history, since absence from a log Orca does not own proves nothing.
|
||||
*/
|
||||
export async function attachJournal(input: {
|
||||
record: AgentSessionRecord
|
||||
params: AgentSessionAttachParams
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
/** The host's open conversation, whose journal the attach adopts. */
|
||||
openConversation: (record: AgentSessionRecord) => Promise<AgentSessionJournal>
|
||||
/** Pre-spawn history; null means unavailable, omitted permits lazy reads for direct callers. */
|
||||
providerHistoryWindow?: ProviderHistoryWindow | null
|
||||
}): Promise<AttachedJournal> {
|
||||
const identity = journalIdentityFor(input.record, input.params)
|
||||
const fence = input.record.lease.runtimeFence
|
||||
const journal = await input.openConversation(input.record)
|
||||
const settled = await reconcileAgainstProviderHistory({
|
||||
adapter: input.adapter,
|
||||
identity,
|
||||
journal,
|
||||
fence,
|
||||
accountHome: input.record.accountHome,
|
||||
...(Object.hasOwn(input, 'providerHistoryWindow')
|
||||
? { history: input.providerHistoryWindow }
|
||||
: {})
|
||||
})
|
||||
return {
|
||||
journal,
|
||||
unconfirmedClientMessageIds: journal
|
||||
.submissions()
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.dispatchState === 'unknown' &&
|
||||
entry.recovered === true &&
|
||||
!settled.includes(entry.clientMessageId)
|
||||
)
|
||||
.filter((entry) => entry.dispatchState === 'unknown' && entry.recovered === true)
|
||||
.map((entry) => entry.clientMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
/** History reads are best effort; journal write failures must propagate to the handle owner. */
|
||||
async function reconcileAgainstProviderHistory(input: {
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
identity: AgentSessionJournalIdentity
|
||||
journal: AgentSessionJournal
|
||||
fence: number
|
||||
accountHome: AgentSessionAccountHome
|
||||
history?: ProviderHistoryWindow | null
|
||||
}): Promise<string[]> {
|
||||
let history = input.history
|
||||
if (history === undefined) {
|
||||
if (!input.adapter.providerHistoryWindow) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
history = await input.adapter.providerHistoryWindow({
|
||||
identity: input.identity,
|
||||
accountHome: input.accountHome
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
if (!history) {
|
||||
return []
|
||||
}
|
||||
return reconcileJournalSubmissionsAgainstHistory({
|
||||
journal: input.journal,
|
||||
fence: input.fence,
|
||||
history
|
||||
})
|
||||
}
|
||||
|
||||
/** Adoption starts at fence 1; resumed links may be elided when identical proof is replayed. */
|
||||
/**
|
||||
* The first link of an adopting session's chain.
|
||||
*
|
||||
* `adopted` is the only origin besides `created` a chain will accept at its head, and it is the
|
||||
* honest one here: this session did not create the conversation. The adapter appends its own
|
||||
* `resumed` link once the provider proves the same identity root — or, when it proves the identical
|
||||
* handle at the same fence, the validator elides that as a retry and this link stays the head.
|
||||
*/
|
||||
const ADOPTED_HANDLE_FENCE = 1
|
||||
|
||||
function adoptedProviderHandleLink(
|
||||
|
||||
+12
-3
@@ -1,5 +1,14 @@
|
||||
// Opening starts no provider. Settle crash-stranded work before readers: handed-over sends become
|
||||
// unknown, provably unhanded personal text becomes cards. History reconciliation later requires a lease.
|
||||
// The one way a conversation's journal becomes open on this host: for a send, for a reader, and
|
||||
// for an attach that finds none open.
|
||||
//
|
||||
// A damaged journal fails the open, which refuses it as unloadable. It marks what an earlier host
|
||||
// process handed over and left unanswered as in doubt, and settles what it left running — the
|
||||
// crash boundary. That
|
||||
// needs no lease, and the doubt is final: whether the provider took such a row is never guessed. A
|
||||
// row an earlier process accepted and never handed over (it quit or crashed first) is settled here
|
||||
// too, before any reader, command or child sees it: a person's message is kept as a card, the rest
|
||||
// rejected (`journal-unsent-send-hold.ts`). The cards then wait for the chat's next turn
|
||||
// (`queued-message-pause.ts`). Nothing here starts a provider child.
|
||||
|
||||
import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
@@ -90,7 +99,7 @@ export async function openStructuredAgentSessionConversationJournal(
|
||||
const identity = journalIdentityFor(record, params)
|
||||
const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase })
|
||||
try {
|
||||
// Provider history may resolve uncertainty only after winning the lease.
|
||||
// A handed-over row found here stays doubt: it reads as sent, and nothing narrows it later.
|
||||
await journal.markPendingSubmissionsUnknown(fence)
|
||||
} catch (error) {
|
||||
deps.logger.warn('marking pending sends unknown on open failed', {
|
||||
|
||||
-7
@@ -65,8 +65,6 @@ function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachPar
|
||||
|
||||
it('imports adoption at rest and resumes its original provider identity after its source disappears', async () => {
|
||||
const params = await adoptedParams()
|
||||
const history = vi.fn(async () => null)
|
||||
rig.host.deps.adapter.providerHistoryWindow = history
|
||||
expect(await rig.host.create(CALLER, params)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -83,7 +81,6 @@ it('imports adoption at rest and resumes its original provider identity after it
|
||||
{ origin: 'adopted', mintedAtFence: 1, handle: { nativeId: THREAD } }
|
||||
])
|
||||
expect(rig.adapter.acquire).not.toHaveBeenCalled()
|
||||
expect(history).not.toHaveBeenCalled()
|
||||
await rm(join(rig.root, 'rollout.jsonl'))
|
||||
expect(
|
||||
await rig.host.create(CALLER, {
|
||||
@@ -101,10 +98,6 @@ it('imports adoption at rest and resumes its original provider identity after it
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(history).toHaveBeenCalledOnce()
|
||||
expect(history.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
rig.adapter.acquire.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome)
|
||||
})
|
||||
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
// Sample before acquiring a child; once running, absent prompts cannot prove non-delivery.
|
||||
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
|
||||
export async function readProviderHistoryWindow(input: {
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
identity: AgentSessionJournalIdentity
|
||||
accountHome: AgentSessionRecord['accountHome']
|
||||
ownerAlreadyAdmitted: boolean
|
||||
}): Promise<ProviderHistoryWindow | null> {
|
||||
const read = input.adapter.providerHistoryWindow
|
||||
if (!read) {
|
||||
return null
|
||||
}
|
||||
let history: ProviderHistoryWindow | null
|
||||
try {
|
||||
history = await read({ identity: input.identity, accountHome: input.accountHome })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// An already-live lease may belong to an unindexed child; retain uncertainty.
|
||||
return history && input.ownerAlreadyAdmitted ? { ...history, turnInFlight: true } : history
|
||||
}
|
||||
+3
-1
@@ -50,6 +50,8 @@ export type QueuedMessageTestRig = Awaited<ReturnType<typeof createQueuedMessage
|
||||
export async function createQueuedMessageTestRig(
|
||||
options: QueuedRigProviderOptions & {
|
||||
idleSweep?: { idleMs: number; intervalMs: number }
|
||||
now?: () => number
|
||||
/** Teardown records its restart offers, which `restartOffers` lists. */
|
||||
recoveryCapsule?: true
|
||||
location?: AgentSessionExecutionLocation
|
||||
/** Defaults to indeterminate. */
|
||||
@@ -70,7 +72,7 @@ export async function createQueuedMessageTestRig(
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-1',
|
||||
now: () => NOW,
|
||||
now: options.now ?? (() => NOW),
|
||||
...(options.probeOwner ? { probeOwner: options.probeOwner } : {}),
|
||||
...(options.idleSweep ? { idleSweep: options.idleSweep } : {}),
|
||||
...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {})
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection'
|
||||
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION as SESSION
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { STRUCTURED_AGENT_SESSION_IDLE_MS as IDLE_MS } from './structured-agent-session-idle-sweep'
|
||||
|
||||
it('keeps a recovered handoff consumed through natural idle unload and a history reopen', async () => {
|
||||
let now = HOST_TEST_NOW
|
||||
const rig = await createQueuedMessageTestRig({
|
||||
restartable: true,
|
||||
now: () => now,
|
||||
idleSweep: { idleMs: IDLE_MS, intervalMs: 2_000_000_000 }
|
||||
})
|
||||
try {
|
||||
const first = await rig.workingSend()
|
||||
await rig.send('handed-over card', 'queue-if-active').result
|
||||
await rig.send('still waiting', 'queue-if-active').result
|
||||
const cards = await rig.drafts()
|
||||
const consumedId = cards[0]!.messageId
|
||||
const waitingId = cards[1]!.messageId
|
||||
await rig.settleAccepted(first, 'initial')
|
||||
await eventually(async () =>
|
||||
expect((await rig.handoff(consumedId))?.handedOverAt).toBeDefined()
|
||||
)
|
||||
const handoffId = await rig.handoffId(consumedId)
|
||||
const dispatches = rig.dispatch.mock.calls.length
|
||||
// Simulate process loss without a graceful close settling the handed-over row.
|
||||
rig.host.collaboratorsForTests().lifetime.idleSweep.dispose()
|
||||
rig.crashRestartHostProcess()
|
||||
const read = async () => {
|
||||
const result = await rig.host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
if (!result.ok) {
|
||||
throw new Error('history refused')
|
||||
}
|
||||
return result.page
|
||||
}
|
||||
const recovered = await read()
|
||||
expect(recovered.submissions.find((row) => row.clientMessageId === handoffId)).toMatchObject({
|
||||
queuedMessageId: consumedId,
|
||||
dispatchState: 'unknown',
|
||||
recovered: true
|
||||
})
|
||||
expect(recovered.queuedMessages?.map((card) => card.messageId)).toEqual([waitingId])
|
||||
const messages = () =>
|
||||
read().then((page) => projectStructuredAgentSessionMessages(page.items, [], page.submissions))
|
||||
const bubble = (await messages()).filter(
|
||||
(message) => message.id === agentJournalSubmissionKey(handoffId)
|
||||
)
|
||||
expect(bubble).toHaveLength(1)
|
||||
expect(bubble[0]?.unsent).toBeUndefined()
|
||||
expect(bubble[0]?.queued).toBeUndefined()
|
||||
now += IDLE_MS + 1
|
||||
await rig.host.collaboratorsForTests().lifetime.idleSweep.tick()
|
||||
expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true)
|
||||
expect(await rig.deleteQueued(waitingId)).toMatchObject({ ok: true })
|
||||
now += IDLE_MS + 1
|
||||
await rig.host.collaboratorsForTests().lifetime.idleSweep.tick()
|
||||
expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false)
|
||||
const reopened = await read()
|
||||
expect(reopened.queuedMessages ?? []).toEqual([])
|
||||
expect(
|
||||
(await messages()).filter((message) => message.id === agentJournalSubmissionKey(handoffId))
|
||||
).toEqual(bubble)
|
||||
expect(rig.dispatch).toHaveBeenCalledTimes(dispatches)
|
||||
} finally {
|
||||
await rig.dispose()
|
||||
}
|
||||
})
|
||||
+2
-14
@@ -57,7 +57,7 @@ export async function startAgent(state: {
|
||||
|
||||
export async function interruptedRestart(
|
||||
work: 'turn' | 'submission' | 'send-after-reply' | 'children' | 'queued-cards' = 'turn',
|
||||
historyBoundaryConsistent = true,
|
||||
/** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */
|
||||
probeOwner: NonNullable<StructuredAgentSessionHostDeps['probeOwner']> = async () => ({
|
||||
outcome: 'pid-absent'
|
||||
}),
|
||||
@@ -145,19 +145,7 @@ export async function interruptedRestart(
|
||||
agents,
|
||||
logger: log.logger,
|
||||
store,
|
||||
adapter: {
|
||||
...adapter(),
|
||||
closeSession,
|
||||
...(work === 'submission' || work === 'send-after-reply'
|
||||
? {
|
||||
providerHistoryWindow: async () => ({
|
||||
items: [],
|
||||
boundaryConsistent: historyBoundaryConsistent,
|
||||
turnInFlight: false
|
||||
})
|
||||
}
|
||||
: {})
|
||||
},
|
||||
adapter: { ...adapter(), closeSession },
|
||||
journalDatabase: openTestJournalHostDatabase(previous.root),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-next',
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ const SUPERSEDED = 'agent_session_restart_work_superseded'
|
||||
const COLD_START = { timeout: 10_000 }
|
||||
|
||||
async function offered(work: 'turn' | 'submission' = 'turn') {
|
||||
const state = await interruptedRestart(work, false)
|
||||
const state = await interruptedRestart(work)
|
||||
expect(await state.host.restartResume.list()).toHaveLength(1)
|
||||
return state
|
||||
}
|
||||
|
||||
+10
-8
@@ -87,7 +87,7 @@ it('reports a failed attribution note without an installed error sink or private
|
||||
it.each(['turn', 'submission'] as const)(
|
||||
'does not continue a marked %s after the user submits new work without a provider echo',
|
||||
async (work) => {
|
||||
const { host, dispatch } = await interruptedRestart(work, false)
|
||||
const { host, dispatch } = await interruptedRestart(work)
|
||||
expect(await host.restartResume.list()).toHaveLength(1)
|
||||
await startAgent(hostTestState())
|
||||
dispatch.mockResolvedValueOnce({ state: 'admitted' })
|
||||
@@ -99,21 +99,22 @@ it.each(['turn', 'submission'] as const)(
|
||||
}
|
||||
)
|
||||
|
||||
it('still continues a chat whose last send acquisition proves was never delivered', async () => {
|
||||
// Teardown judged the chat working. Whether the agent got the cut-off send is never guessed: it stays
|
||||
// sent (STA-7416), and the continuation asks the agent to check what finished.
|
||||
it('keeps a send a restart cut off as sent, and still continues its chat', async () => {
|
||||
const { host, acquire, dispatch } = await interruptedRestart('submission')
|
||||
expect(await host.restartResume.list()).toHaveLength(1)
|
||||
const result = await host.restartResume.continueAfterRestart([SESSION], 'modal')
|
||||
expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({
|
||||
dispatchState: 'rejected',
|
||||
rejection: { kind: 'notDelivered' }
|
||||
})
|
||||
const [cut] = (await host.journalSnapshot(SESSION)).submissions
|
||||
expect(cut).toMatchObject({ dispatchState: 'unknown', recovered: true })
|
||||
expect(cut).not.toHaveProperty('rejection')
|
||||
expect(acquire).toHaveBeenCalledTimes(1)
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
expect(result.continued).toMatchObject([{ outcome: 'continued' }])
|
||||
})
|
||||
|
||||
it('offers and continues a send made after an earlier completed turn', async () => {
|
||||
const { host, dispatch, marker } = await interruptedRestart('send-after-reply', false)
|
||||
const { host, dispatch, marker } = await interruptedRestart('send-after-reply')
|
||||
expect(marker?.work.kind).toBe('submission')
|
||||
const result = await host.restartResume.continueAfterRestart([SESSION], 'modal')
|
||||
expect(result.continued).toMatchObject([{ outcome: 'continued' }])
|
||||
@@ -468,7 +469,8 @@ it('logs teardown capsule publication failure and still releases the provider',
|
||||
|
||||
// Renderer guidance keys on refusal code; retain details beside it.
|
||||
it('files a restart refused by a conflicted claim under its code, with its details beside it', async () => {
|
||||
const { host, store } = await interruptedRestart('turn', true, async () => ({
|
||||
// The terminal agent that holds the claim is still running, so nothing may take it over.
|
||||
const { host, store } = await interruptedRestart('turn', async () => ({
|
||||
outcome: 'identity-matched',
|
||||
matchedOn: ['spawn-token']
|
||||
}))
|
||||
|
||||
-3
@@ -27,7 +27,6 @@ function codexOnAnotherTransport(): StructuredAgentRegistry {
|
||||
|
||||
it('does not offer to resume a chat whose agent this build no longer drives, and keeps the offer', async () => {
|
||||
const { host, root, acquire } = await interruptedRestart(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
codexOnAnotherTransport()
|
||||
@@ -44,7 +43,6 @@ it('does not offer to resume a chat whose agent this build no longer drives, and
|
||||
|
||||
it('lists a recorded failure of such a chat as not retryable', async () => {
|
||||
const { host, root, marker } = await interruptedRestart(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
codexOnAnotherTransport()
|
||||
@@ -73,7 +71,6 @@ it('lists a recorded failure of such a chat as not retryable', async () => {
|
||||
|
||||
it('keeps such a chat readable and refuses to start its agent', async () => {
|
||||
const { host, store, acquire } = await interruptedRestart(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
codexOnAnotherTransport()
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// Derive cuts from the journal’s retained old epoch so admission and recovery agree without copying history.
|
||||
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import { isAgentSessionRewindTarget } from '../../../shared/agent-session-rewind'
|
||||
import { savedRewindKeys, isAgentSessionRewindTarget } from '../../../shared/agent-session-rewind'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer'
|
||||
import {
|
||||
agentJournalSubmissionKey,
|
||||
parseAgentJournalItemKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
AgentJournalRenderItem
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement'
|
||||
@@ -39,9 +34,9 @@ export function structuredRewindCut(
|
||||
const items = floor
|
||||
? snapshot.items.filter((item) => item.sequence > floor.sequence)
|
||||
: snapshot.items
|
||||
const { providerKey, omitted } = submissionKeys(snapshot.submissions)
|
||||
const { providerKey, omitted, nativeIdentity } = savedRewindKeys(snapshot.submissions)
|
||||
const selected = items.find((item) => item.itemId === target.itemId)
|
||||
const key = selected ? parseAgentJournalItemKey(providerKey(selected.itemId)) : null
|
||||
const key = selected ? nativeIdentity(selected.itemId) : null
|
||||
if (key?.provider === 'claude' && target.provider === 'claude') {
|
||||
// Messages before an earlier fork still cut at their original position.
|
||||
const boundary = items.findIndex((item) => item.itemId === target.itemId)
|
||||
@@ -130,26 +125,7 @@ function lastListedTurn(
|
||||
}
|
||||
|
||||
export function providerItemIdOf(journal: AgentSessionJournal, itemId: string): string {
|
||||
return submissionKeys(journal.snapshot().submissions).providerKey(itemId)
|
||||
}
|
||||
|
||||
// Rebuilt epochs lose submission links, so accepted messages must adopt provider keys.
|
||||
function submissionKeys(submissions: readonly AgentJournalSubmission[]): {
|
||||
providerKey: (itemId: string) => string
|
||||
/** Queued messages survive separately; rejected messages never ran. */
|
||||
omitted: ReadonlySet<string>
|
||||
} {
|
||||
const providerKeys = new Map<string, string>()
|
||||
const omitted = new Set<string>()
|
||||
for (const submission of submissions) {
|
||||
const key = agentJournalSubmissionKey(submission.clientMessageId)
|
||||
if (isQueuedAgentJournalSubmission(submission) || submission.dispatchState === 'rejected') {
|
||||
omitted.add(key)
|
||||
} else if (submission.dispatchState === 'accepted' && submission.providerItemId) {
|
||||
providerKeys.set(key, submission.providerItemId)
|
||||
}
|
||||
}
|
||||
return { providerKey: (itemId) => providerKeys.get(itemId) ?? itemId, omitted }
|
||||
return savedRewindKeys(journal.snapshot().submissions).providerKey(itemId)
|
||||
}
|
||||
|
||||
/** Child rows belong to their enclosing main turn, never their child-thread turn ID. */
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { readOpenCodeTranscriptPage } from '../native-chat/transcript-opencode-sqlite-query'
|
||||
import {
|
||||
openCodeStoredUserMessagesReader,
|
||||
type OpenCodeTranscriptPageReader
|
||||
} from './opencode-acp-stored-messages'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
/** An `opencode.db` under a fresh XDG data home, built by `schema`; returns that data home. */
|
||||
function dataHomeWith(schema: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-opencode-stored-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'opencode'))
|
||||
const db = new Database(join(root, 'opencode', 'opencode.db'))
|
||||
db.exec(schema)
|
||||
db.close()
|
||||
return root
|
||||
}
|
||||
|
||||
/** The bounded reader in-process, standing in for its worker. */
|
||||
const readInProcess: OpenCodeTranscriptPageReader = async (args) => readOpenCodeTranscriptPage(args)
|
||||
|
||||
function readFrom(dataHome: string) {
|
||||
return openCodeStoredUserMessagesReader(readInProcess)({
|
||||
env: { HOME: dataHome, USERPROFILE: dataHome, XDG_DATA_HOME: dataHome },
|
||||
providerSessionId: 'ses_1',
|
||||
signal: new AbortController().signal
|
||||
})
|
||||
}
|
||||
|
||||
const OPENCODE_2_TABLES = `CREATE TABLE session_v2 (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER,
|
||||
data TEXT, time_created INTEGER, time_updated INTEGER);`
|
||||
|
||||
describe('OpenCode stored user messages', () => {
|
||||
it("reads the database under the chat's own home, and only its user messages", async () => {
|
||||
const readPage = vi.fn<OpenCodeTranscriptPageReader>(async () => ({
|
||||
items: [
|
||||
{
|
||||
rowid: 1,
|
||||
fingerprint: 'a',
|
||||
message: { id: 'msg_1', role: 'user', blocks: [], timestamp: 5, source: 'transcript' }
|
||||
},
|
||||
{
|
||||
rowid: 2,
|
||||
fingerprint: 'b',
|
||||
message: {
|
||||
id: 'msg_2',
|
||||
role: 'assistant',
|
||||
blocks: [],
|
||||
timestamp: 6,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
],
|
||||
hasMore: false,
|
||||
beforeMessageRowId: 1
|
||||
}))
|
||||
const home = join('/', 'child-home')
|
||||
const read = openCodeStoredUserMessagesReader(readPage)
|
||||
await expect(
|
||||
read({
|
||||
env: { HOME: home, USERPROFILE: home },
|
||||
providerSessionId: 'ses_1',
|
||||
signal: new AbortController().signal
|
||||
})
|
||||
).resolves.toEqual([{ id: 'msg_1', blocks: [], createdAt: 5 }])
|
||||
expect(readPage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dbPath: join(home, '.local', 'share', 'opencode', 'opencode.db'),
|
||||
sessionId: 'ses_1'
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a 2.x session from its live tables, not the frozen 1.x copy an upgrade left', async () => {
|
||||
const dataHome = dataHomeWith(`${OPENCODE_2_TABLES}
|
||||
CREATE TABLE session (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER,
|
||||
data TEXT);
|
||||
CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT);
|
||||
INSERT INTO session VALUES ('ses_1');
|
||||
INSERT INTO message VALUES ('old', 'ses_1', 1, 1, '{"role":"user"}');
|
||||
INSERT INTO part VALUES ('p', 'old', 'ses_1', 1, '{"type":"text","text":"frozen"}');
|
||||
INSERT INTO session_v2 VALUES ('ses_1');
|
||||
INSERT INTO session_message VALUES
|
||||
('u1', 'ses_1', 'user', 1, '{"text":"live prompt"}', 50, 50),
|
||||
('a1', 'ses_1', 'assistant', 2, '{"content":[{"type":"text","text":"reply"}]}', 60, 60);`)
|
||||
await expect(readFrom(dataHome)).resolves.toEqual([
|
||||
{ id: 'opencode:u1', blocks: [{ type: 'text', text: 'live prompt' }], createdAt: 50 }
|
||||
])
|
||||
})
|
||||
|
||||
it('finds nothing in a 2.x database whose message table it does not know', async () => {
|
||||
const dataHome = dataHomeWith(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, body TEXT);
|
||||
INSERT INTO session_v2 VALUES ('ses_1');`)
|
||||
await expect(readFrom(dataHome)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { OPENCODE_TRANSCRIPT_MAX_WINDOW } from '../../shared/opencode-transcript-page-limit'
|
||||
import type { AcpStoredUserMessagesReader } from '../acp/acp-recovery-history'
|
||||
import { resolveOpenCodeDatabasePath } from './opencode-data-directory'
|
||||
import type { readOpenCodeTranscriptPageViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn'
|
||||
|
||||
export type OpenCodeTranscriptPageReader = typeof readOpenCodeTranscriptPageViaWorker
|
||||
|
||||
/** The latest user messages of an OpenCode session, read from the database its pinned account
|
||||
* selects, through the existing bounded reader: 2.x's `session_v2` rows first, else 1.x's. */
|
||||
export function openCodeStoredUserMessagesReader(
|
||||
readPage?: OpenCodeTranscriptPageReader
|
||||
): AcpStoredUserMessagesReader {
|
||||
return async ({ env, providerSessionId, signal }) => {
|
||||
// The child's own home, which its account may name, not this process's.
|
||||
const home = (process.platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir()
|
||||
const dbPath = resolveOpenCodeDatabasePath(env, home)
|
||||
if (!dbPath) {
|
||||
return null
|
||||
}
|
||||
const read =
|
||||
readPage ??
|
||||
(await import('../ai-vault/session-scanner-opencode-sqlite-worker-spawn'))
|
||||
.readOpenCodeTranscriptPageViaWorker
|
||||
const page = await read(
|
||||
{ dbPath, sessionId: providerSessionId, limit: OPENCODE_TRANSCRIPT_MAX_WINDOW },
|
||||
signal
|
||||
)
|
||||
if (!page) {
|
||||
return null
|
||||
}
|
||||
return page.items.flatMap(({ message }) =>
|
||||
message.role === 'user' && message.timestamp !== null
|
||||
? [{ id: message.id, blocks: message.blocks, createdAt: message.timestamp }]
|
||||
: []
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -87,12 +87,7 @@ it('fences a dismissed offer against a late teardown write when the desktop sees
|
||||
})
|
||||
|
||||
it('leaves offers it was not shown when the host runs an agent an older desktop cannot show', async () => {
|
||||
const { host, root, marker } = await interruptedRestart(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
withPilot()
|
||||
)
|
||||
const { host, root, marker } = await interruptedRestart(undefined, undefined, withPilot())
|
||||
setStructuredAgentSessionHost(host)
|
||||
const capsule = new AgentSessionRecoveryCapsule(root)
|
||||
// An offer whose chat this launch cannot read names no agent the desktop was shown.
|
||||
|
||||
@@ -147,7 +147,6 @@ function createAcpAdapter(
|
||||
})
|
||||
return new AcpStructuredSessionAdapter({
|
||||
spec,
|
||||
readJournal,
|
||||
resolveLaunch: composeStructuredLaunch(definition, sources, part),
|
||||
readLaunch: composeStructuredLaunchRead(definition, sources, part),
|
||||
dataDirectory: deps.stateDirectory,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import * as sessionCreate from './rpc/methods/structured-agent-session-create'
|
||||
import { attachParams, providerFaults } from './structured-chat-coordinator-fake-codex-fixture'
|
||||
import {
|
||||
COORDINATOR,
|
||||
call,
|
||||
codex,
|
||||
coordinatorRunAndTask,
|
||||
db,
|
||||
host,
|
||||
openChat,
|
||||
runtime
|
||||
} from './structured-chat-coordinator-mail-rig.test-fixture'
|
||||
|
||||
it('keeps a minted worker start unknown after one preamble handover without an echo', async () => {
|
||||
await openChat(COORDINATOR)
|
||||
const { taskId } = await coordinatorRunAndTask()
|
||||
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
|
||||
vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ supported: true })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: placement reads only the workspace identity in this rig.
|
||||
vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({
|
||||
id: 'workspace-1',
|
||||
repoId: 'repo'
|
||||
} as never)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch-mode selection reads only getSettings on this store double.
|
||||
vi.spyOn(runtime, 'requireStore').mockReturnValue({
|
||||
getSettings: () => ({
|
||||
experimentalNativeChat: true,
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: {},
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
} as never)
|
||||
vi.spyOn(sessionCreate, 'createStructuredAgentSessionForWorktree').mockImplementation(
|
||||
async (args) => {
|
||||
const attached = await host.attach(args.caller, attachParams(args.envelope.sessionId))
|
||||
if (!attached.ok) {
|
||||
throw new Error('scripted worker attach refused')
|
||||
}
|
||||
return {
|
||||
...attached,
|
||||
value: { ...attached.value, sessionId: args.envelope.sessionId, fence: attached.fence }
|
||||
}
|
||||
}
|
||||
)
|
||||
providerFaults.dieBeforeEveryEcho = true
|
||||
const receipt = await call(
|
||||
'orchestration.workerStart',
|
||||
{
|
||||
task: taskId,
|
||||
agent: 'codex',
|
||||
worktree: 'current'
|
||||
},
|
||||
{ sessionId: COORDINATOR }
|
||||
)
|
||||
|
||||
expect(receipt).toMatchObject({
|
||||
state: 'outcome_unknown',
|
||||
lastError: expect.stringMatching(/not acknowledged/)
|
||||
})
|
||||
expect(JSON.stringify(receipt.nextCommands)).toContain('worker-show')
|
||||
expect(JSON.stringify(receipt.nextCommands)).toContain('worker-abandon')
|
||||
const dispatchId = String(receipt.dispatchId)
|
||||
expect(db.getWorkerDispatch(dispatchId)?.state).toBe('start_unknown')
|
||||
const worker = codex.connections.at(-1)!
|
||||
const sessionId = db.getDispatchContextById(dispatchId)?.assignee_orca_session_id
|
||||
if (!sessionId) {
|
||||
throw new Error('minted worker session missing')
|
||||
}
|
||||
const snapshot = await host.journalSnapshot(sessionId)
|
||||
expect(snapshot.submissions).toHaveLength(1)
|
||||
expect(snapshot.submissions[0]).toMatchObject({ dispatchState: 'unknown' })
|
||||
expect(worker.turns).toHaveLength(1)
|
||||
expect(providerFaults.turnStarts).toBe(1)
|
||||
})
|
||||
@@ -236,6 +236,12 @@ it('keeps preparation and runtime failures from starting a terminal', async () =
|
||||
})
|
||||
const ensure = vi.spyOn(runtime, 'ensureStructuredAgentSessionHost')
|
||||
for (const [failure, expected] of [
|
||||
[
|
||||
new Error(
|
||||
'Could not confirm this conversation was saved. Try again after the agent responds.'
|
||||
),
|
||||
{ message: 'chat_fork_source_unavailable' }
|
||||
],
|
||||
[
|
||||
new Error('This chat has no saved conversation to copy.'),
|
||||
{ message: 'chat_fork_source_unavailable' }
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { useStructuredAgentSessionRewindBlockedReason } from './StructuredAgentSessionStatusBridge'
|
||||
import type { NativeChatComposerHandle } from './NativeChatComposer'
|
||||
import type { NativeChatRewindHost } from './use-native-chat-rewind'
|
||||
import type { AgentSessionRewindReason } from '../../../../shared/agent-session-rewind'
|
||||
|
||||
export type NativeChatRewindSurface = {
|
||||
disabledReason: string | null
|
||||
eligibleItemIds?: ReadonlySet<string>
|
||||
request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise<void>
|
||||
}
|
||||
|
||||
/** What the pane hosting a structured session tells its rewind. */
|
||||
export type NativeChatRewindHost = {
|
||||
/** The host's in-doubt latch, from its status feed. */
|
||||
hostBlockedReason?: AgentSessionRewindReason | null
|
||||
onMessageReturned?: () => void
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
/** What a pane hands its session's rewind: the host's in-doubt latch, its visibility, and where a
|
||||
* returned message lands. `focusComposer` is the same focus the pane's other give-backs use. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
||||
import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
savedRewindKeys,
|
||||
isAgentSessionRewindTarget,
|
||||
type AgentSessionRewindReason,
|
||||
type AgentSessionRewindResult,
|
||||
@@ -24,18 +25,13 @@ import type {
|
||||
} from './use-structured-agent-session-mutate'
|
||||
import { returnMessageToComposer } from './structured-agent-session-message-hand-back'
|
||||
import { nativeChatRewindOffered } from './native-chat-rewind-eligibility'
|
||||
import type { NativeChatRewindSurface } from './use-native-chat-rewind-host'
|
||||
import {
|
||||
agentSessionContextFloorSequence,
|
||||
latestAgentSessionContextClearSequence
|
||||
} from '../../../../shared/agent-session-context-clear'
|
||||
import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types'
|
||||
|
||||
export type NativeChatRewindSurface = {
|
||||
disabledReason: string | null
|
||||
eligibleItemIds?: ReadonlySet<string>
|
||||
request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise<void>
|
||||
}
|
||||
|
||||
/** How long a confirmed rewind waits for its new conversation before letting go. */
|
||||
export const NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS = 120_000
|
||||
|
||||
@@ -81,12 +77,13 @@ function rewindFailureReason(failure: AgentSessionWriteFailure): string | undefi
|
||||
}
|
||||
|
||||
function isRewindTarget(input: RewindInput, itemId: string): boolean {
|
||||
const floor = rewindContextFloor(input)
|
||||
const opener = turnOpeners(input.state.items)
|
||||
const keys = savedRewindKeys(input.state.submissions)
|
||||
return input.state.items.some(
|
||||
(item) =>
|
||||
item.itemId === itemId &&
|
||||
item.sequence > floor &&
|
||||
item.sequence > rewindContextFloor(input) &&
|
||||
(keys.nativeIdentity(itemId) !== null || !keys.hasSubmission(itemId)) &&
|
||||
isAgentSessionRewindTarget(item, (turn) => opener.get(turn))
|
||||
)
|
||||
}
|
||||
@@ -304,15 +301,18 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
const floor = rewindContextFloor(input)
|
||||
const eligibleItemIds = useMemo(() => {
|
||||
const opener = turnOpeners(input.state.items)
|
||||
const keys = savedRewindKeys(input.state.submissions)
|
||||
return new Set(
|
||||
input.state.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.sequence > floor && isAgentSessionRewindTarget(item, (turn) => opener.get(turn))
|
||||
item.sequence > floor &&
|
||||
(keys.nativeIdentity(item.itemId) !== null || !keys.hasSubmission(item.itemId)) &&
|
||||
isAgentSessionRewindTarget(item, (turn) => opener.get(turn))
|
||||
)
|
||||
.map((item) => item.itemId)
|
||||
)
|
||||
}, [floor, input.state.items])
|
||||
}, [floor, input.state.items, input.state.submissions])
|
||||
// Rows re-render only when this changes; none get the action where the provider can never rewind.
|
||||
const surface = useMemo<NativeChatRewindSurface | undefined>(
|
||||
() => (offered ? { disabledReason, request, eligibleItemIds } : undefined),
|
||||
@@ -321,10 +321,4 @@ export function useNativeChatRewind(input: RewindInput) {
|
||||
return { request, admitsSend, unlessBlocked, surface, disabledReason, pending, blockedRef }
|
||||
}
|
||||
|
||||
/** What the pane hosting a structured session tells its rewind. */
|
||||
export type NativeChatRewindHost = {
|
||||
/** The host's in-doubt latch, from its status feed. */
|
||||
hostBlockedReason?: AgentSessionRewindReason | null
|
||||
onMessageReturned?: () => void
|
||||
isVisible?: boolean
|
||||
}
|
||||
export type { NativeChatRewindHost, NativeChatRewindSurface } from './use-native-chat-rewind-host'
|
||||
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
AgentJournalProducerLinkageFields,
|
||||
AgentJournalTurnScopeSchema
|
||||
} from './agent-session-journal-schemas'
|
||||
import { parseAgentJournalItemKey } from './agent-session-journal-item-key'
|
||||
import type { AgentJournalRenderItem } from './agent-session-journal-types'
|
||||
import {
|
||||
agentJournalSubmissionKey,
|
||||
parseAgentJournalItemKey
|
||||
} from './agent-session-journal-item-key'
|
||||
import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission'
|
||||
import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types'
|
||||
import type { AgentSessionMutationEnvelope } from './agent-session-wire'
|
||||
|
||||
export const AGENT_SESSION_REWIND_REASONS = [
|
||||
@@ -85,6 +89,31 @@ export const AgentSessionRewindRecordSchema = z.object({
|
||||
export type AgentSessionRewindRecord = z.infer<typeof AgentSessionRewindRecordSchema>
|
||||
export type AgentSessionRewindFork = NonNullable<AgentSessionRewindRecord['providerFork']>
|
||||
|
||||
// Accepted messages keep Orca's key until a rewind rebuilds the epoch with provider keys.
|
||||
export function savedRewindKeys(submissions: readonly AgentJournalSubmission[]) {
|
||||
const providerKeys = new Map<string, string>()
|
||||
const omitted = new Set<string>()
|
||||
for (const submission of submissions) {
|
||||
const key = agentJournalSubmissionKey(submission.clientMessageId)
|
||||
providerKeys.set(key, key)
|
||||
if (isQueuedAgentJournalSubmission(submission) || submission.dispatchState === 'rejected') {
|
||||
omitted.add(key)
|
||||
} else if (submission.dispatchState === 'accepted' && submission.providerItemId) {
|
||||
providerKeys.set(key, submission.providerItemId)
|
||||
}
|
||||
}
|
||||
const providerKey = (itemId: string) => providerKeys.get(itemId) ?? itemId
|
||||
return {
|
||||
providerKey,
|
||||
omitted,
|
||||
hasSubmission: (itemId: string) => providerKeys.has(itemId),
|
||||
nativeIdentity: (itemId: string) => {
|
||||
const key = parseAgentJournalItemKey(providerKey(itemId))
|
||||
return key?.provider === 'claude' || key?.provider === 'codex' ? key : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A message a rewind can return to: a user message that opened its own turn. One sent into a
|
||||
* running turn belongs to that turn, whose provider and journal order it differently, so a rewind
|
||||
* goes back only to whole turns. `turnOpener` reads the message a turn record names as its opener,
|
||||
|
||||
@@ -335,7 +335,7 @@ export type AgentSessionAttachResult = {
|
||||
sessionId: string
|
||||
fence: number
|
||||
page: AgentSessionHistoryPage
|
||||
/** Submissions a crash boundary left `unknown` that provider history could not decide. */
|
||||
/** Submissions a crash boundary left `unknown`. No client reads it; kept for older peers. */
|
||||
unconfirmedClientMessageIds: string[]
|
||||
/** The host-owned id of the tab showing this chat, when it has one. Absent from older hosts. */
|
||||
tabId?: string
|
||||
|
||||
@@ -37,8 +37,8 @@ export const DISPATCH_REJECTED_HOST_RESTARTED = 'host_restarted_before_delivery'
|
||||
* Read only, like the one above. */
|
||||
export const DISPATCH_REJECTED_PROVIDER_CLOSED = 'provider_closed_before_delivery'
|
||||
|
||||
/** Restart reconciliation found the send absent from a provider history it could trust. Rows
|
||||
* written before rows carried a fact hold it as their reason; released clients printed it. */
|
||||
/** Legacy marker: an older host's restart guess that the send was absent from provider history.
|
||||
* Read only, like the ones above; released clients printed it. */
|
||||
export const DISPATCH_REJECTED_NOT_DELIVERED = 'not_delivered'
|
||||
|
||||
/** True for the internal transport marker, false for a provider's own words. Legacy-reason half
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
agentJournalSubmissionKey
|
||||
} from '../../src/shared/agent-session-journal-item-key'
|
||||
import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../src/shared/structured-agent-session-reducer'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../src/shared/agent-session-journal-types'
|
||||
import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding'
|
||||
import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { structuredRewindCut } from '../../src/main/native-chat/agent-session-wire/structured-rewind-cut'
|
||||
import { useNativeChatRewind } from '../../src/renderer/src/components/native-chat/use-native-chat-rewind'
|
||||
|
||||
const IDENTITY = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('main')
|
||||
}
|
||||
const journals = createTrackedJournalOpener()
|
||||
let root: string
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('offers a recovered opener only when its saved native alias arrives, retaining prior history', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-rewind-boundary-'))
|
||||
const options = { identity: IDENTITY, stateDirectory: root }
|
||||
const journal = await journals.open(options)
|
||||
const native = (uuid: string) => ({ provider: 'claude' as const, sessionId: 'original', uuid })
|
||||
const blocks = [{ type: 'text' as const, text: 'prompt' }]
|
||||
const body = { kind: 'message' as const, role: 'user' as const, blocks }
|
||||
const attribution = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
await journal.appendItem(native('earlier'), body, attribution)
|
||||
await journal.appendItem(native('reply'), { ...body, role: 'assistant' }, attribution)
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'lost',
|
||||
payloadFingerprint: 'lost',
|
||||
body,
|
||||
fence: 1
|
||||
})
|
||||
await journals.closeAll()
|
||||
const recovered = await journals.open(options)
|
||||
await recovered.markPendingSubmissionsUnknown(2)
|
||||
const snapshot = recovered.snapshot()
|
||||
const target = agentJournalSubmissionKey('lost')
|
||||
expect(snapshot.submissions.find((row) => row.clientMessageId === 'lost')).toMatchObject({
|
||||
dispatchState: 'unknown',
|
||||
recovered: true
|
||||
})
|
||||
const state = {
|
||||
...EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
...snapshot,
|
||||
epoch: snapshot.cursor.epoch,
|
||||
status: 'ready' as const,
|
||||
fence: 2
|
||||
}
|
||||
const props = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
capturePresentation: () => () => true,
|
||||
state,
|
||||
support: { supported: true as const },
|
||||
blocked: false,
|
||||
send: vi.fn()
|
||||
}
|
||||
const view = renderHook((input) => useNativeChatRewind(input), { initialProps: props })
|
||||
try {
|
||||
expect(view.result.current.surface?.eligibleItemIds).not.toContain(target)
|
||||
const cursor = recovered.cursor()
|
||||
expect(
|
||||
structuredRewindCut(recovered, { itemId: target, provider: 'claude', nativeId: 'forked' })
|
||||
).toBeNull()
|
||||
expect(recovered.cursor()).toEqual(cursor)
|
||||
view.rerender({ ...props, state: { ...state, submissions: [] } })
|
||||
expect(view.result.current.surface?.eligibleItemIds).toContain(target)
|
||||
await recovered.acceptLate({
|
||||
clientMessageId: 'lost',
|
||||
state: 'accepted',
|
||||
providerIdentity: native('genuine'),
|
||||
fence: 2
|
||||
})
|
||||
view.rerender({ ...props, state: { ...state, submissions: recovered.snapshot().submissions } })
|
||||
expect(view.result.current.surface?.eligibleItemIds).toContain(target)
|
||||
const cut = structuredRewindCut(recovered, {
|
||||
itemId: target,
|
||||
provider: 'claude',
|
||||
nativeId: 'forked'
|
||||
})
|
||||
expect(cut).toMatchObject({ beforeMessage: { sessionId: 'original', uuid: 'genuine' } })
|
||||
expect(cut?.kept.map(({ identity }) => agentJournalItemKey(identity))).toEqual([
|
||||
agentJournalItemKey(native('earlier')),
|
||||
agentJournalItemKey(native('reply'))
|
||||
])
|
||||
} finally {
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user