Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0784bc3
fix(native-chat): a read whose history will not open is refused with …
brennanb2025 Sep 28, 2026
1122921
refactor(native-chat): the idle sweep's stop of a hung start carries …
brennanb2025 Sep 28, 2026
101d4e6
fix(native-chat): a refusal the host throws is worded from its data, …
brennanb2025 Sep 28, 2026
5c9b4cb
fix(native-chat): an unreadable history is worded from its refusal, a…
brennanb2025 Sep 28, 2026
91cda82
fix(native-chat): a rejected message is worded from its stored fact
brennanb2025 Sep 28, 2026
2ec46a9
fix(native-chat): each message that did not go through says why on it…
brennanb2025 Sep 28, 2026
8985ffc
fix(native-chat): say each chat failure's words where its own control…
brennanb2025 Sep 28, 2026
26f0c99
fix(native-chat): a rejected message offers Retry only once the queue…
brennanb2025 Sep 28, 2026
d29a373
fix(native-chat): a chat whose history will not open logs once, not o…
brennanb2025 Sep 28, 2026
55f2513
fix(native-chat): a message's own Retry is its resend step, so its no…
brennanb2025 Sep 28, 2026
5298d8d
fix(native-chat): a message's Retry sends only that message, never th…
brennanb2025 Sep 28, 2026
9e131b2
fix(native-chat): a not-signed-in failure beside Retry still says to …
brennanb2025 Sep 28, 2026
0eb4c54
test(native-chat): a rejected message's hidden Retry only avoids wait…
brennanb2025 Sep 28, 2026
8ac3d2d
fix(native-chat): every Retry beside a notice leaves out the retry st…
brennanb2025 Sep 28, 2026
f3b8d2f
test(native-chat): read the sent message id without a type assertion
brennanb2025 Sep 28, 2026
bac487a
fix(native-chat): a rejected message is worded from the journal's own…
brennanb2025 Sep 28, 2026
8ff7387
fix(native-chat): a message rejected before a restart retries under a…
brennanb2025 Sep 28, 2026
4fd67d7
fix(native-chat): a rejected message older than the loaded history ke…
brennanb2025 Sep 28, 2026
5c1647d
test(native-chat): the chat pane words a rejected message from its lo…
brennanb2025 Sep 28, 2026
0b9a7b0
fix(native-chat): a chat whose history won't load says why in one line
brennanb2025 Sep 28, 2026
e3f23a8
fix(native-chat): a message a failed start rejected says only that it…
brennanb2025 Sep 28, 2026
c2eb689
fix(native-chat): a chat whose history keeps failing to open retries …
brennanb2025 Sep 28, 2026
708e1cc
test(native-chat): the pane harness types its journal rows without a …
brennanb2025 Sep 28, 2026
5e41e50
test(native-chat): the admission test passes no start-failure rows to…
brennanb2025 Sep 28, 2026
f7892dc
test(native-chat): import the journal types once
brennanb2025 Sep 28, 2026
4c03975
fix(native-chat): a remote chat reads again as soon as its host is back
brennanb2025 Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
import { openJournalDatabase } from './journal-database'
import { classifyJournalOpenFailure } from './journal-open-failure'
import {
classifyJournalOpenFailure,
createJournalOpenReadRefusals,
journalOpenReadRefusal
} from './journal-open-failure'
import { loadJournal } from './journal-open'
import { journalDatabaseFile } from './journal-paths'

Expand Down Expand Up @@ -104,3 +109,51 @@ describe('classifyJournalOpenFailure', () => {
expect(classifyJournalOpenFailure(first)).toBe('journalUnavailable')
})
})

describe('journalOpenReadRefusal', () => {
it('names the reason, keeps the message the code and the storage text only as the cause', () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const storage = nodeSqliteError(26)
const refusal = journalOpenReadRefusal(storage)
expect(refusal.message).toBe('agent_session_journal_unreadable')
expect(refusal.refusal).toMatchObject({
code: 'agent_session_journal_unreadable',
details: { reason: 'journalCorrupt' }
})
expect(refusal.cause).toBe(storage)
vi.restoreAllMocks()
})

it('passes a refusal the open already raised through unchanged', () => {
const raised = agentSessionRefusalError('agent_session_identity_required', {
reason: 'recordMissing'
})
expect(journalOpenReadRefusal(raised)).toBe(raised)
})
})

describe('createJournalOpenReadRefusals', () => {
it('logs a session once per failure until it opens, and each session on its own', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const refusals = createJournalOpenReadRefusals()
const denied = systemError('EACCES', -13)
const corrupt = nodeSqliteError(26)

for (let attempt = 0; attempt < 3; attempt += 1) {
const refusal = refusals.refusal('session-1', denied)
expect(refusal.refusal).toMatchObject({ details: { reason: 'journalUnavailable' } })
expect(refusal.cause).toBe(denied)
}
expect(warn).toHaveBeenCalledTimes(1)
refusals.refusal('session-2', denied)
expect(warn).toHaveBeenCalledTimes(2)
expect(refusals.refusal('session-1', corrupt).refusal).toMatchObject({
details: { reason: 'journalCorrupt' }
})
expect(warn).toHaveBeenCalledTimes(3)
refusals.forget('session-1')
refusals.refusal('session-1', corrupt)
expect(warn).toHaveBeenCalledTimes(4)
vi.restoreAllMocks()
})
})
58 changes: 58 additions & 0 deletions src/main/native-chat/agent-session-journal/journal-open-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
// past, or the open failed in a way that can clear (a lock, permissions, too many open files).

import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details'
import {
AgentSessionRefusalError,
isAgentSessionRefusalError,
refuse
} from '../../../shared/agent-session-wire-refusals'
import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure'

export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'>
Expand All @@ -20,3 +25,56 @@ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure {
}
return 'journalUnavailable'
}

/**
* What a read throws when the conversation it reaches cannot be opened. The storage's own text
* (a path, "file is not a database") goes to the log only; the reader gets the classified refusal,
* whose message stays the bare code.
*/
export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError {
if (isAgentSessionRefusalError(error)) {
return error
}
return unreadableRefusal(error, classifyJournalOpenFailure(error), true)
}

const MAX_LOGGED_SESSIONS = 256

/**
* The read door's refusals for one host. A reader reconnects on a timer while an open can clear,
* so a session's failure is logged once until that session opens or the failure changes.
*/
export function createJournalOpenReadRefusals() {
const logged = new Map<string, string>()
return {
refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => {
if (isAgentSessionRefusalError(error)) {
return error
}
const reason = classifyJournalOpenFailure(error)
const failure = `${reason}:${error instanceof Error ? error.message : String(error)}`
const repeat = logged.get(sessionId) === failure
// Past the cap a new session logs every failure rather than evict another's.
if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) {
logged.set(sessionId, failure)
}
return unreadableRefusal(error, reason, !repeat)
},
/** The session opened or closed: its next failure is news. */
forget: (sessionId: string): void => {
logged.delete(sessionId)
}
}
}

function unreadableRefusal(
error: unknown,
reason: JournalOpenFailure,
log: boolean
): AgentSessionRefusalError {
if (log) {
console.warn('[agent-session] opening the conversation for a read failed:', error)
}
const code = 'agent_session_journal_unreadable'
return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error })
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
// public entry point here takes the session's serialize once and calls the under-serialize forms,
// because the queue is not reentrant.

import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names'
import {
AgentSessionRefusalError,
agentSessionRefusalError
} from '../../../shared/agent-session-wire-refusals'
import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure'
import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations'
import {
abandonQueuedStructuredAgentSessionMessages,
Expand Down Expand Up @@ -40,6 +40,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
let disposed = false
const { sessions, serialize } = host
const deps = () => host.context().deps
const readRefusals = createJournalOpenReadRefusals()
// The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause.
const stopAgent = (sessionId: string) =>
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId)
Expand Down Expand Up @@ -69,12 +70,11 @@ export function createStructuredAgentSessionConversationLifetime(host: {
return record !== null && deps().hasOpenDispatch?.(record) === true
},
stopAgent,
// A host stop with its reason: the delivery loop waiting on this child writes the one error
// row and rejects what is queued with it.
// A host stop: the delivery loop waiting on this child writes the one error row and rejects
// what is queued with it, both worded from the hostStopped fact.
stopStartingAgent: (sessionId) =>
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, {
cause: 'host-stop',
reason: `${TUI_AGENT_DISPLAY_NAMES[sessions.get(sessionId)?.params.provider ?? 'claude']} never finished starting, so Orca stopped it.`
cause: 'host-stop'
}),
closeConversation,
onError: (sessionId, error) => deps().onEventSinkError?.({ sessionId, error }),
Expand All @@ -98,6 +98,7 @@ export function createStructuredAgentSessionConversationLifetime(host: {
conversation: async (sessionId: string): Promise<StructuredAgentSessionHostSession> => {
const open = sessions.get(sessionId)
if (open) {
readRefusals.forget(sessionId)
return open
}
const record = deps().store.getRecord(sessionId)
Expand All @@ -116,19 +117,23 @@ export function createStructuredAgentSessionConversationLifetime(host: {
if (disposed) {
throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED)
}
const session = await host.open(sessionId)
const session = await host.open(sessionId).catch((error: unknown) => {
throw readRefusals.refusal(sessionId, error)
})
if (!session) {
throw agentSessionRefusalError('agent_session_identity_required', {
reason: 'recordMissing'
})
}
readRefusals.forget(sessionId)
return session
})
},
/** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is
* still queued will not be sent. */
close: (sessionId: string): Promise<void> =>
serialize(sessionId, async () => {
readRefusals.forget(sessionId)
const session = sessions.get(sessionId)
if (session) {
// Abandoned before the stop, so no start delivers it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../../../shared/agent-session-wire'
import { decodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure'
import { isClaudeStructuredOptionKey } from '../../claude/claude-structured-options'
import { isCodexTurnOptionKey } from '../../codex/codex-structured-turn-start'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
Expand Down Expand Up @@ -88,7 +89,9 @@ export async function readStructuredAgentSessionOptions(
): Promise<AgentSessionOptionsResult> {
const { adapter, store } = context.deps
const live = await context.serialize(sessionId, async () => {
const session = await context.openConversation(sessionId)
const session = await context.openConversation(sessionId).catch((error: unknown) => {
throw journalOpenReadRefusal(error)
})
const child = session?.child
if (!child) {
return null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key'
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text'
Expand All @@ -21,7 +22,7 @@ export function structuredAgentSessionStartFailureRow(
): JournalLifecycleMutationInput {
return {
kind: 'item',
identity: startFailureRowIdentity(startKey),
identity: structuredAgentSessionStartFailureRowIdentity(startKey),
// The row repeats the sentence the start's rejected messages carry.
body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }
}
Expand All @@ -33,14 +34,10 @@ export function hasStructuredAgentSessionStartFailureRow(
items: readonly { itemId: string }[],
startKey: string
): boolean {
const itemId = agentJournalItemKey(startFailureRowIdentity(startKey))
const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey))
return items.some((item) => item.itemId === itemId)
}

function startFailureRowIdentity(startKey: string) {
return { provider: 'orca' as const, clientMessageId: `start-failure:${startKey}` }
}

/**
* A start the delivery loop needed and did not get: the start's row, and every queued message
* rejected with the same words. Writes nothing when nothing is still queued: a start whose
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,78 @@ describe('the accessor', () => {
})
})

it('refuses a read whose journal will not open with the classified reason, never the storage text', async () => {
await restingChat()
const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open')
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const storagePath = '/Users/someone/.orca/journals/session-1/journal.sqlite'
const failWith = (error: Error): void => {
open.mockRejectedValue(error)
}
const failures = async (): Promise<RpcResponse[]> =>
[
...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' })),
...(await call('agentSession.subscribe', { sessionId: SESSION })),
...(await call('agentSession.options', { sessionId: SESSION }))
].filter((reply) => !reply.ok)

failWith(
Object.assign(new Error(`file is not a database: ${storagePath}`), {
code: 'ERR_SQLITE_ERROR',
errcode: 26
})
)
const corrupt = await failures()
failWith(Object.assign(new Error(`EACCES: permission denied, open '${storagePath}'`), {}))
const unavailable = await failures()

for (const [replies, reason] of [
[corrupt, 'journalCorrupt'],
[unavailable, 'journalUnavailable']
] as const) {
expect(replies).toHaveLength(3)
for (const reply of replies) {
expect(reply).toMatchObject({
ok: false,
error: {
// Not a passthrough code: released clients read the message, which stays the code.
code: 'runtime_error',
message: 'agent_session_journal_unreadable',
data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } }
}
})
expect(JSON.stringify(reply)).not.toContain(storagePath)
}
}
})

it('logs a reader reconnecting to a journal that will not open once per failure', async () => {
await restingChat()
const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const logged = (): unknown[] =>
warn.mock.calls
.filter(([line]) => line === '[agent-session] opening the conversation for a read failed:')
.map(([, error]) => error)
const reconnect = async (): Promise<RpcResponse[]> => [
...(await call('agentSession.subscribe', { sessionId: SESSION })),
...(await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }))
]
const denied = new Error('EACCES: permission denied')
const exhausted = new Error('EMFILE: too many open files')

open.mockRejectedValue(denied)
for (let attempt = 0; attempt < 4; attempt += 1) {
// Every attempt is still refused with its reason; only the log is quiet.
expect((await reconnect()).filter((reply) => !reply.ok)).toHaveLength(2)
}
expect(logged()).toEqual([denied])
open.mockRejectedValue(exhausted)
await reconnect()
await reconnect()
expect(logged()).toEqual([denied, exhausted])
})

it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => {
await foundRestTestChat(rig)
await rig.host.flushAllStreamedEvents()
Expand Down

This file was deleted.

Loading
Loading