Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
11009c5
fix(native-chat): a child's root exit is reported even during its clo…
brennanb2025 Oct 2, 2026
b9667b5
fix(native-chat): the exit ends the child's record; an unfinished sto…
brennanb2025 Oct 2, 2026
103598f
fix(native-chat): a message refused because the old process's exit is…
brennanb2025 Oct 2, 2026
902354e
fix(native-chat): a close's verdict is the root's exit alone, and wha…
brennanb2025 Oct 2, 2026
11fb593
fix(native-chat): one handler ends every child's exit, and a join wai…
brennanb2025 Oct 2, 2026
e97710f
fix(native-chat): an exit's lease release lands whatever the length o…
brennanb2025 Oct 2, 2026
ce52df9
fix(claude): a proven close waits at most 2 s for the output it alrea…
brennanb2025 Oct 2, 2026
1d315b8
fix(codex): an exit reported inside Orca's close keeps the reason Orc…
brennanb2025 Oct 2, 2026
31819db
fix(native-chat): quit stops delivery before it drains exit recovery
brennanb2025 Oct 2, 2026
aa7928c
docs(native-chat): the unverifiable-exit refusal no longer names a ca…
brennanb2025 Oct 2, 2026
121150b
fix(native-chat): a stop whose kill did not take is logged, and the n…
brennanb2025 Oct 2, 2026
1f4a19c
fix(native-chat): a start refused over the old process says Orca coul…
brennanb2025 Oct 2, 2026
54512de
docs(native-chat): an unverifiable close verdict is a root that survi…
brennanb2025 Oct 2, 2026
0a98f69
fix(native-chat): a kill that did not take is reported once, by whoev…
brennanb2025 Oct 2, 2026
6cf2520
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 2, 2026
45c746d
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 3, 2026
1bf8f29
test(native-chat): a second Stop joins a close the first could not pr…
brennanb2025 Oct 3, 2026
83073d8
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 3, 2026
8fb8aaf
fix(native-chat): say a start refused beside an unstopped process pla…
brennanb2025 Oct 3, 2026
baf85fc
fix(native-chat): never drop an agent process end Orca could not confirm
brennanb2025 Oct 5, 2026
b9c4247
fix(native-chat): a stop that takes over a fault's close writes no St…
brennanb2025 Oct 5, 2026
96416f3
test(native-chat): pin where a chat close that takes over a fault's c…
brennanb2025 Oct 5, 2026
aca48b4
docs(native-chat): say a stop that takes over a fault's close keeps i…
brennanb2025 Oct 5, 2026
bea13c4
Merge remote-tracking branch 'origin/main' into brennanb2025/native-c…
brennanb2025 Oct 5, 2026
4081e1f
fix(native-chat): an unproven end's close records no Stop event as nu…
brennanb2025 Oct 5, 2026
4453a86
Merge remote-tracking branch 'origin/main' into brennanb2025/native-c…
brennanb2025 Oct 5, 2026
7d2bc39
Merge remote-tracking branch 'origin/main' into brennanb2025/native-c…
brennanb2025 Oct 6, 2026
35721d4
Merge remote-tracking branch 'origin/main' into brennanb2025/native-c…
brennanb2025 Oct 6, 2026
f5e48b6
test(native-chat): build the unproven-end tests' host and handles as …
brennanb2025 Oct 6, 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
36 changes: 17 additions & 19 deletions src/main/claude/claude-structured-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,14 @@ import {
type ClaudeStructuredSessionAdapterDeps,
type ClaudeStructuredSessionEvent
} from './claude-structured-session-state'
import {
closeAllClaudeSessions,
closeClaudeSession,
finishClaudeCloseAfterExit
} from './claude-structured-session-close'
import { closeAllClaudeSessions, closeClaudeSession } from './claude-structured-session-close'
import { claudeStoppedRequestEndWait } from './claude-request-end-wait'
import {
drainClaudeObservedExits,
finishClaudeExitedClose,
observeClaudeSessionExit,
settleClaudeUnexpectedExit,
withClaudeSessionOrRejected,
type ClaudeExitLifecycle
} from './claude-structured-session-exit-lifecycle'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
Expand Down Expand Up @@ -66,15 +64,15 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
private readonly sessions = new Map<string, ClaudeSession>()
private readonly acquisitions = new ClaudeAcquisitionRegistry()
private readonly exits = new Map<string, ClaudeSessionExit>()
private readonly settledExitErrors = new Map<string, Error>()
private readonly settledExits: ClaudeExitLifecycle['settledExits'] = new Map()
private readonly exitLifecycle: ClaudeExitLifecycle

constructor(private readonly deps: ClaudeStructuredSessionAdapterDeps) {
this.atRestCommands = deps.atRestCommands
this.exitLifecycle = {
sessions: this.sessions,
exits: this.exits,
settledExitErrors: this.settledExitErrors,
settledExits: this.settledExits,
deps,
emit: (session, event) => this.emit(session, event)
}
Expand All @@ -83,7 +81,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
supportsLocation = supportsClaudeStructuredLocation

acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> => {
this.settledExitErrors.delete(input.identity.sessionId)
this.settledExits.delete(input.identity.sessionId)
return acquireClaudeSession({
input,
deps: this.deps,
Expand All @@ -96,13 +94,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
handleExit: (sessionId, attempt, error) =>
observeClaudeSessionExit(this.exitLifecycle, sessionId, attempt, error),
finishClose: (sessionId, attempt) =>
finishClaudeCloseAfterExit({
sessions: this.sessions,
sessionId,
connection: attempt.connection,
deps: this.deps,
afterClose: (close) => this.afterClose(sessionId, close)
}),
finishClaudeExitedClose(this.exitLifecycle, sessionId, attempt.connection, (close) =>
this.afterClose(sessionId, close)
),
Comment on lines 67 to +99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

git diff --name-only baf85fcc958c9cf108f9d76bec45c1e69be1c87d f5e48b6f2c539e5a0f5b6d4b0e9fee782c21ebd8 -- 'src/main/claude/claude-structured-*'
rg -n 'Real Claude CLI|ORCA_REAL_CLAUDE_CLI_TEST|Verifying Changes' AGENTS.md src/main/claude package.json 2>/dev/null | head -65

Repository: stablyai/orca

Length of output: 3035


Run the required real Claude CLI tests on this head.

The reported 8/8 pass predates changes to Claude structured-session code, and the PR says the tests were not rerun. The project requires this run when that code changes:

ORCA_REAL_CLAUDE_CLI_TEST=1 pnpm test src/main/claude/claude-structured-real-cli.test.ts src/main/claude/claude-structured-real-cli-fold.test.ts

settleExit: (sessionId, exit) =>
settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit)
}
Expand Down Expand Up @@ -172,10 +166,14 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
}

dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch)
withClaudeSessionOrRejected(this.exitLifecycle, input.sessionId, (session) =>
dispatchClaudeTurn(session, input, input.beforeDispatch)
)

compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) =>
dispatchClaudeCommand(this.session(input.sessionId), input.command)
withClaudeSessionOrRejected(this.exitLifecycle, input.sessionId, (session) =>
dispatchClaudeCommand(session, input.command)
)

cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) =>
cancelClaudeStructuredTurn({
Expand Down Expand Up @@ -272,7 +270,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda

closeSession = (sessionId: string): Promise<boolean> =>
// After the close, not before: releasing an exit still settling settles it on the way.
this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId))
this.closeSessionProcess(sessionId).finally(() => this.settledExits.delete(sessionId))

private closeSessionProcess(sessionId: string): Promise<boolean> {
// An exit seen first settles as that exit, whoever asked for the close after it.
Expand Down Expand Up @@ -307,7 +305,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
// A child that just exited is named by its own diagnostic, not by its absence.
throw (
this.exits.get(sessionId)?.error ??
this.settledExitErrors.get(sessionId) ??
this.settledExits.get(sessionId)?.error ??
new Error(`no live claude stream-json session for ${sessionId}`)
)
}
Expand Down
38 changes: 38 additions & 0 deletions src/main/claude/claude-structured-session-exit-blame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { describe, expect, it, vi } from 'vitest'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn'
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter'
import {
adapterFor,
Expand Down Expand Up @@ -63,4 +64,41 @@ describe('what a started Claude session says ended it', () => {
})
expect(connection.closeCount).toBeGreaterThanOrEqual(1)
})

it('rejects a message or a compaction for a child it no longer holds, with why it ended', async () => {
const claude = fakeClaude()
const adapter = adapterFor(claude, {}, [])
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
const connection = claude.connections[0]
// The close cannot prove the child gone, so the adapter lets go of it and keeps its exit.
connection.close = async () => false
connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): killed'))
await adapter.drainObservedExits()

const rejected = {
state: 'rejected',
rejection: { kind: 'providerExited' }
}
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
fence: 7
})
).resolves.toMatchObject(rejected)
const turn = structuredAgentSessionCommandTurn('client-2')
await expect(
adapter.compact({
sessionId: 'session-1',
fence: 7,
command: {
clientMessageId: 'client-2',
...turn,
running: { kind: 'turn', turnId: turn.turnId, state: 'running' }
}
})
).resolves.toMatchObject(rejected)
expect(connection.sent).toEqual([])
})
})
124 changes: 109 additions & 15 deletions src/main/claude/claude-structured-session-exit-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure'
import {
agentSessionFailureFact,
providerDiagnosticOf,
type SubmissionRejectionFact
} from '../../shared/agent-session-failure'
import {
providerExitObserved,
providerStartupFailureFact
} from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { claudeDispatchRejection } from './claude-structured-dispatch-content'
import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection'
import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point'
import {
claudeRootExitObserved,
finishClaudeCloseAfterExit,
settleClaudeExitedSession
} from './claude-structured-session-close'
import { failClaudeStartup } from './claude-structured-session-startup-state'
Expand All @@ -17,12 +25,15 @@ import type {
ClaudeStructuredSessionEvent
} from './claude-structured-session-state'

export type ClaudeSettledExit = { error: Error; failure: SubmissionRejectionFact }

export type ClaudeExitLifecycle = {
sessions: Map<string, ClaudeSession>
exits: Map<string, ClaudeSessionExit>
/** A settled exit's diagnostic, kept for a send admitted before the host heard of the exit. */
settledExitErrors: Map<string, Error>
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'persistHandle' | 'now'>
/** A settled exit's diagnostic and why it ended, kept for a send admitted before the host heard
* of the exit. */
settledExits: Map<string, ClaudeSettledExit>
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'persistHandle' | 'now' | 'onEvent' | 'logger'>
emit: (session: ClaudeSession, event: ClaudeStructuredSessionEvent) => void
}

Expand All @@ -34,6 +45,7 @@ export function observeClaudeSessionExit(
): void {
const session = lifecycle.sessions.get(sessionId)
if (!session || session.connection !== attempt.connection) {
settleRetainedClaudeRootExit(lifecycle, sessionId, attempt.connection)
return
}
lifecycle.sessions.delete(sessionId)
Expand All @@ -55,13 +67,81 @@ export function observeClaudeSessionExit(
.then((proven) => {
// A first-hand root exit is final like a proven one: the owner releases the lease on it.
if (!proven && !claudeRootExitObserved(session.connection)) {
reportClaudeUnprovenEnd(lifecycle, sessionId, exit)
return undefined
}
return settleClaudeUnexpectedExit(lifecycle, sessionId, exit)
})
.catch(() => undefined)
}

/** The root exited after a close began: that close settles it, or one the adapter began after
* letting the session go ends the exit it kept. */
export function finishClaudeExitedClose(
lifecycle: ClaudeExitLifecycle,
sessionId: string,
connection: ClaudeStreamJsonConnection | null,
afterClose: (close: () => Promise<boolean>) => Promise<boolean>
): void {
settleRetainedClaudeRootExit(lifecycle, sessionId, connection)
finishClaudeCloseAfterExit({
sessions: lifecycle.sessions,
sessionId,
connection,
deps: lifecycle.deps,
afterClose
})
}

/** The root of an exit whose close could not prove it gone has now exited: the end it reported
* unproven is final. Only the retained exit's own connection reports it, and only once that
* exit's first close has answered, which publishes the end itself when it saw the root go. */
function settleRetainedClaudeRootExit(
lifecycle: ClaudeExitLifecycle,
sessionId: string,
connection: ClaudeStreamJsonConnection | null
): void {
const exit = lifecycle.exits.get(sessionId)
if (!exit || exit.connection !== connection) {
return
}
const published = exit.publication ?? Promise.resolve()
exit.publication = published.then(() =>
lifecycle.exits.get(sessionId) === exit && claudeRootExitObserved(exit.connection)
? settleClaudeUnexpectedExit(lifecycle, sessionId, exit).catch(() => undefined)
: undefined
)
}

/** Never silence: the host keeps the child on record, closing, until its exit is proven or seen. */
function reportClaudeUnprovenEnd(
lifecycle: ClaudeExitLifecycle,
sessionId: string,
exit: ClaudeSessionExit
): void {
if (lifecycle.exits.get(sessionId) !== exit) {
return
}
lifecycle.deps.onEvent?.({
type: 'end-unproven',
sessionId,
reason: exit.error.message,
failure: claudeExitFailure(exit),
fence: exit.session.fence,
acquisitionGeneration: exit.session.acquisitionGeneration
})
}

/** A start that never landed says why it failed. After it landed, only the child's own exit blames
* the provider; an Orca fault that closed it is Orca's. */
function claudeExitFailure(exit: Pick<ClaudeSessionExit, 'session' | 'error'>) {
return exit.session.startup.state !== 'proven'
? providerStartupFailureFact(exit.session.startup.failure ?? exit.error)
: providerExitObserved(exit.error)
? agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(exit.error) })
: agentSessionFailureFact('hostFault')
}

/** Lifecycle recovery is published only after the close ladder ran and proved the tree gone or
* observed the root's own exit. */
export function settleClaudeUnexpectedExit(
Expand All @@ -87,21 +167,12 @@ export function settleClaudeUnexpectedExit(
return
}
exits.delete(sessionId)
lifecycle.settledExitErrors.set(sessionId, exit.error)
lifecycle.settledExits.set(sessionId, { error: exit.error, failure: claudeExitFailure(exit) })
const ended: ClaudeStructuredSessionEvent = {
type: 'ended',
sessionId,
reason: exit.error.message,
// A start that never landed says why it failed. After it landed, only the child's own exit
// blames the provider; an Orca fault that closed it is Orca's.
failure:
exit.session.startup.state !== 'proven'
? providerStartupFailureFact(exit.session.startup.failure ?? exit.error)
: providerExitObserved(exit.error)
? agentSessionFailureFact('providerExited', {
detail: providerDiagnosticOf(exit.error)
})
: agentSessionFailureFact('hostFault'),
failure: claudeExitFailure(exit),
cause: 'unexpected-exit',
fence: exit.session.fence,
acquisitionGeneration: exit.session.acquisitionGeneration,
Expand All @@ -117,6 +188,29 @@ export function settleClaudeUnexpectedExit(
return exit.settlementPromise
}

/** A message for a child this adapter no longer serves was never written, so it is rejected with
* why that child ended, never left in doubt. */
function rejectClaudeDetachedDispatch(
lifecycle: Pick<ClaudeExitLifecycle, 'exits' | 'settledExits'>,
sessionId: string
): AgentSessionDispatchOutcome {
const exit = lifecycle.exits.get(sessionId)
const failure = exit
? claudeExitFailure(exit)
: (lifecycle.settledExits.get(sessionId)?.failure ?? agentSessionFailureFact('providerExited'))
return { state: 'rejected', ...claudeDispatchRejection(failure) }
}

/** Runs `run` on the live session, or rejects what was meant for a child this adapter let go of. */
export async function withClaudeSessionOrRejected<T>(
lifecycle: Pick<ClaudeExitLifecycle, 'sessions' | 'exits' | 'settledExits'>,
sessionId: string,
run: (session: ClaudeSession) => Promise<T>
): Promise<T | AgentSessionDispatchOutcome> {
const session = lifecycle.sessions.get(sessionId)
return session ? run(session) : rejectClaudeDetachedDispatch(lifecycle, sessionId)
}

/** Wait for each first-hand exit's publication, including exits observed while waiting. */
export async function drainClaudeObservedExits(
exits: Map<string, ClaudeSessionExit>
Expand Down
6 changes: 5 additions & 1 deletion src/main/claude/claude-structured-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type {
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
StructuredAgentSessionEndUnprovenEvent,
StructuredAgentSessionStartedEvent
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type {
ClaudeStreamJsonConnection,
openClaudeStreamJsonConnection
Expand Down Expand Up @@ -67,6 +70,7 @@ export type ClaudeStructuredSessionEvent =
| { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic }
/** Startup facts applied and saved options restored; held prompts are about to be written. */
| StructuredAgentSessionStartedEvent
| StructuredAgentSessionEndUnprovenEvent
| {
type: 'ended'
sessionId: string
Expand Down
4 changes: 4 additions & 0 deletions src/main/codex/codex-structured-journal-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export function createCodexJournalTranslator(
})
},
handle: (event) => {
// Host lifecycle only: the journal records this child's end once its exit is seen.
if (event.type === 'end-unproven') {
return CODEX_JOURNAL_ADMITTED
}
if (event.type === 'ended') {
const streamAdmission = flushStreams()
if (!streamAdmission.accepted) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,29 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
body: USER_MESSAGE,
fence: 7
})
).rejects.toThrow('no live codex app-server')
// Never written to a child it no longer serves: rejected, never left in doubt.
).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'providerExited' } })
expect(adapter.backgroundTaskStops('session-1')).toBeDefined()
await expect(adapter.closeSession('session-1')).resolves.toBe(false)
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
})

it('rejects a message for a connection that broke while the child may still run, without saying Codex stopped', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
codex.connections[0].closed = true

await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'writeFailed' } })
expect(codex.connections[0].calls.some((call) => call.method === 'turn/start')).toBe(false)
})

it('keeps the live session when a child it already replaced dies', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
Expand Down
Loading
Loading